82 lines
5.1 KiB
PHP
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;
|