[dirname(__DIR__), 'sgf_tempo_dev', 'sgf_dev_app', 55432], 'demo' => ['C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/development-app', 'sgf_modules_dev_2576427d1939', 'sgf_modules_qa_2576427d1939', 55439], ]; $target = $argv[1] ?? ''; if (count($argv) !== 2 || !isset($targets[$target])) { fwrite(STDERR, "Usage: php scripts/module-associations-inventory.php installed|demo\n"); exit(1); } [$root, $database, $role, $port] = $targets[$target]; require $root.'/vendor/autoload.php'; $app = require $root.'/bootstrap/app.php'; $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); $db = Illuminate\Support\Facades\DB::connection(); $db->beginTransaction(); try { $db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'); $db->statement("SET LOCAL statement_timeout = '15s'"); $identity = $db->selectOne('SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting(\'transaction_read_only\') AS read_only'); if ($identity->database !== $database || $identity->role !== $role || (int) $identity->port !== $port || $identity->read_only !== 'on') { throw new RuntimeException('Unexpected database identity or writable transaction.'); } $areas = []; foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) { // Same effective-state rule as the application: latest event per // tenant/company/area/record/module; a removal never restores a default. $cte = "WITH latest AS ( SELECT DISTINCT ON (tenant_id,company_id,details::jsonb->>'record_id',details::jsonb->>'module_id') tenant_id,company_id,details::jsonb->>'record_id' AS record_id,action FROM identity_audit WHERE action IN ('module.record.associated','module.record.disassociated') AND details::jsonb->>'area' = ? ORDER BY tenant_id,company_id,details::jsonb->>'record_id',details::jsonb->>'module_id',id DESC ), inventory AS ( SELECT r.id,r.tenant_id,r.company_id,r.unit_id,r.version, count(l.record_id) AS reviewed_links, count(l.record_id) FILTER (WHERE l.action='module.record.associated') AS active_links FROM a_{$area} r LEFT JOIN latest l ON l.tenant_id=r.tenant_id AND l.company_id=r.company_id AND l.record_id=r.id::text GROUP BY r.id,r.tenant_id,r.company_id,r.unit_id,r.version ) "; $scopes = $db->select($cte."SELECT tenant_id,company_id,unit_id,count(*) AS total, count(*) FILTER (WHERE active_links>0) AS associated, count(*) FILTER (WHERE reviewed_links=0) AS never_associated, count(*) FILTER (WHERE reviewed_links>0 AND active_links=0) AS all_links_removed FROM inventory GROUP BY tenant_id,company_id,unit_id ORDER BY tenant_id,company_id,unit_id", [$area]); $sample = $db->select($cte."SELECT id,tenant_id,company_id,unit_id,version, CASE WHEN reviewed_links=0 THEN 'never_associated' ELSE 'all_links_removed' END AS reason FROM inventory WHERE active_links=0 ORDER BY tenant_id,company_id,unit_id,id LIMIT 100", [$area]); if (in_array($area, ['service_drafts', 'billing_drafts'], true)) { foreach ($sample as $row) { $columns = ['client_id', 'data']; if ($area === 'billing_drafts') { $columns[] = 'source_id'; } $record = $db->table('a_'.$area)->where('tenant_id', $row->tenant_id) ->where('company_id', $row->company_id)->where('unit_id', $row->unit_id) ->where('id', $row->id)->first($columns); $data = json_decode($record->data, true, 512, JSON_THROW_ON_ERROR); $row->references = ['client_id' => (int) $record->client_id, 'service_ids' => array_values(array_unique(array_column($data['lines'] ?? [], 'service_id')))]; if ($area === 'billing_drafts') { $row->references['source_id'] = (int) $record->source_id; $row->references['source_version'] = $data['source_version'] ?? null; } } } $pending = array_sum(array_map(fn ($s) => (int) $s->never_associated + (int) $s->all_links_removed, $scopes)); $areas[$area] = ['scopes' => $scopes, 'pending_total' => $pending, 'pending_sample' => $sample, 'sample_truncated' => $pending > count($sample)]; } $report = ['captured_at' => gmdate('c'), 'target' => $target, 'identity' => $identity, 'method' => 'Repeatable-read read-only transaction; latest explicit event per module; no business names or document contents exported.', 'classification_limit' => 'Never associated is a current state, not evidence of age or an inferred destination. Removed links require separate review.', 'areas' => $areas, 'writes_performed' => false]; } finally { $db->rollBack(); } echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;