This commit is contained in:
2026-11-02 20:29:34 +02:00
parent 340f3bb5fa
commit fcf31e8d48
181 changed files with 33383 additions and 51 deletions
@@ -101,24 +101,112 @@ final class ModuleAssociationReview
default => null,
};
if (($d['search'] ?? '') !== '') $query->where('name', 'ilike', '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $d['search']).'%');
$page = $query->orderBy('id')->paginate($d['per_page'] ?? 25, ['id', 'name', 'version'], 'page', $d['page'] ?? 1);
$rows = [];
foreach ($page->items() as $row) {
$links = $this->links($area, $row->id);
$deps = []; $restricted = false;
try {
$graph = []; $this->graph($area, $row->id, $graph);
unset($graph["$area:$row->id"]);
$deps = array_values(array_map(fn ($g) => array_diff_key($g, ['checksum' => true]), $graph));
} catch (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $e) { $restricted = true;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $restricted = true; }
$rows[] = [...(array) $row, ...$links, 'dependencies' => $deps, 'dependencies_restricted' => $restricted];
}
$page = $query->orderBy('id')->paginate($d['per_page'] ?? 25, $this->pageColumns($area, true), 'page', $d['page'] ?? 1);
$rows = $this->describePage($area, $page->items(), $scope);
return ['records' => [$area => $rows], 'modules' => app(ModuleNavigation::class)->available($this->context),
'pagination' => ['page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
'notice' => 'Cada decisão exige conferência individual. Dependências não são associadas automaticamente.'];
}
/** Request-local data only: the selected page and its explicit dependency closure.
* Never reused by preview/confirm, which still read and lock current state.
*/
private function describePage(string $area, array $roots, array $scope): array
{
$records = []; $edges = []; $scopes = [$area => $scope]; $pending = [];
foreach ($roots as $row) { $records[$area][$row->id] = $row; $pending["$area:$row->id"] = [$area, (int) $row->id]; }
while ($pending) {
$needed = [];
foreach ($pending as $key => [$kind, $id]) {
$row = $records[$kind][$id]; $refs = [];
if (isset($row->client_id)) $refs[] = ['clients', (int) $row->client_id];
if (isset($row->source_id)) $refs[] = ['service_drafts', (int) $row->source_id];
if (str_ends_with($kind, '_drafts')) {
foreach (json_decode($row->association_service_ids, true, 512, JSON_THROW_ON_ERROR) as $serviceId) $refs[] = ['services', (int) $serviceId];
}
$edges[$key] = $refs;
foreach ($refs as [$depArea, $depId]) if (!array_key_exists($depId, $records[$depArea] ?? [])) $needed[$depArea][$depId] = $depId;
}
$pending = [];
foreach ($needed as $depArea => $ids) {
if (!array_key_exists($depArea, $scopes)) {
try { $scopes[$depArea] = $this->scope($depArea); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $scopes[$depArea] = null; }
}
foreach ($ids as $id) $records[$depArea][$id] = null;
if ($scopes[$depArea] === null) continue;
foreach (array_chunk(array_values($ids), 500) as $chunk) {
foreach (DB::table('a_'.$depArea)->where($scopes[$depArea])->whereIn('id', $chunk)->get($this->pageColumns($depArea)) as $row) {
$records[$depArea][$row->id] = $row;
$pending["$depArea:$row->id"] = [$depArea, (int) $row->id];
}
}
}
}
$states = $this->pageStates($records);
$out = [];
foreach ($roots as $row) {
$deps = []; $restricted = false;
try { $seen = []; $this->walkPage($area, $row->id, $records, $edges, $states, $seen); unset($seen["$area:$row->id"]); $deps = array_values($seen); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $restricted = true; }
$out[] = ['id' => $row->id, 'name' => $row->name, 'version' => $row->version, ...$states[$area][$row->id], 'dependencies' => $deps, 'dependencies_restricted' => $restricted];
}
return $out;
}
/** Fetch metadata and ordered references, not document bodies. This projection
* is only for listing; the decision snapshot still hashes the complete rows.
*/
private function pageColumns(string $area, bool $root = false): array
{
$columns = $root ? ['id', 'name', 'version'] : ['id', 'version'];
if (str_ends_with($area, '_drafts')) {
$columns[] = 'client_id';
if ($area === 'billing_drafts') $columns[] = 'source_id';
$columns[] = DB::raw("(SELECT coalesce(jsonb_agg(ref.line->>'service_id' ORDER BY ref.ord)
FILTER (WHERE ref.line->>'service_id' IS NOT NULL),'[]'::jsonb)
FROM jsonb_array_elements(data->'lines') WITH ORDINALITY AS ref(line,ord)) AS association_service_ids");
}
return $columns;
}
private function walkPage(string $area, int $id, array $records, array $edges, array $states, array &$seen): void
{
$key = "$area:$id";
if (isset($seen[$key])) return;
$row = $records[$area][$id] ?? null;
abort_unless($row, 403, 'Dependência fora do âmbito autorizado.');
$seen[$key] = ['area' => $area, 'id' => $id, 'version' => (int) $row->version, ...$states[$area][$id]];
foreach ($edges[$key] ?? [] as [$depArea, $depId]) $this->walkPage($depArea, $depId, $records, $edges, $states, $seen);
}
/** SQL reduces history to one state per selected record; no event universe in PHP.
* Review-only events advance revision but never create an active association.
*/
private function pageStates(array $records): array
{
$out = [];
foreach ($records as $area => $items) {
$ids = [];
foreach ($items as $id => $row) if ($row !== null) { $ids[] = (string) $id; $out[$area][$id] = ['modules' => [], 'revision' => 0]; }
foreach (array_chunk($ids, 500) as $chunk) {
$q = DB::table('identity_audit')->where($this->context->identifiers())
->whereIn('action', ['module.record.associated', 'module.record.disassociated', 'module.record.reviewed'])
->whereRaw("details::jsonb->>'area' = ?", [$area])->whereIn(DB::raw("details::jsonb->>'record_id'"), $chunk)
->selectRaw("id,action,details::jsonb->>'record_id' AS record_id,details::jsonb->>'module_id' AS module_id");
$sql = "WITH scoped AS MATERIALIZED (".$q->toSql()."),
latest AS (SELECT DISTINCT ON (record_id,module_id) record_id,module_id,action FROM scoped
WHERE action <> 'module.record.reviewed' ORDER BY record_id,module_id,id DESC),
revisions AS (SELECT record_id,max(id) AS revision FROM scoped GROUP BY record_id)
SELECT r.record_id,r.revision,coalesce(jsonb_agg(l.module_id ORDER BY l.module_id)
FILTER (WHERE l.action='module.record.associated'),'[]'::jsonb) AS modules
FROM revisions r LEFT JOIN latest l ON l.record_id=r.record_id GROUP BY r.record_id,r.revision";
foreach (DB::select($sql, $q->getBindings()) as $row) $out[$area][(int) $row->record_id] = ['modules' => json_decode($row->modules, true, 512, JSON_THROW_ON_ERROR), 'revision' => (int) $row->revision];
}
}
return $out;
}
public function preview(Request $r): array
{
$d = $this->input($r);