Files
SGF/scripts/module-associations-inventory.php
T
2026-11-02 20:29:34 +02:00

82 lines
5.1 KiB
PHP

<?php
// Administrative CLI inventory only. Never infer or write module associations.
$targets = [
'installed' => [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;