Exige motivo na reversão territorial e prepara novo plano

This commit is contained in:
2026-10-09 02:15:36 +02:00
parent 6a71aa47e3
commit 6c7a9c9442
9 changed files with 129 additions and 11 deletions
@@ -21,9 +21,10 @@ class InvestmentRelationshipRepairController extends Controller
public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse
{
if ($request->input('operation') === 'traceability') {
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean']]);
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean'],
'reason' => ['required_if:reverse,true', 'nullable', 'string', 'max:1000'], 'confirm_reverse' => ['accepted_if:reverse,true']]);
try {
$result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse']);
$result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse'], $data['reason'] ?? null);
} catch (HttpException $exception) {
if ($request->expectsJson()) {
throw $exception;
@@ -37,7 +37,12 @@ class InvestmentTerritorialTraceability
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->first();
return ['hash' => $hash, 'plan' => $plan, 'code_matches' => ($plan['code_hash'] ?? '') === $this->fingerprint(),
'applied' => $last?->action === 'traceability_applied', 'backup_ready' => $this->backupReady($plan['target'], $hash)];
'applied' => $last?->action === 'traceability_applied', 'reversed' => $last?->action === 'traceability_reversed',
'history' => DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->orderByDesc('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at', 'after_data'])->map(function ($event) {
$data = json_decode($event->after_data, true);
return ['id' => $event->id, 'action' => $event->action, 'actor_user_id' => $event->actor_user_id, 'occurred_at' => $event->occurred_at, 'reason' => $data['reason'] ?? null];
})->all(), 'backup_ready' => $this->backupReady($plan['target'], $hash)];
}
/** @param array<string,mixed> $target */
@@ -67,8 +72,9 @@ class InvestmentTerritorialTraceability
}
/** @return array<string,mixed> */
public function apply(string $hash, int $actor, bool $reverse = false): array
public function apply(string $hash, int $actor, bool $reverse = false, ?string $reason = null): array
{
abort_if($reverse && trim($reason ?? '') === '', 422, 'Indique o motivo da reversão.');
$preview = $this->preview();
abort_unless($preview && hash_equals($preview['hash'], $hash) && $preview['code_matches'], 409, 'Plano ou código alterado. Reveja a pré-visualização.');
$plan = $preview['plan'];
@@ -79,7 +85,7 @@ class InvestmentTerritorialTraceability
}
abort_unless($preview['backup_ready'], 422, 'Backup/restauro/reversão verificados e atualizados são necessários.');
return DB::transaction(function () use ($plan, $hash, $actor, $reverse) {
return DB::transaction(function () use ($plan, $hash, $actor, $reverse, $reason) {
DB::select('SELECT pg_advisory_xact_lock(81026, 160000)');
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->lockForUpdate()->first();
if ((! $reverse && $last?->action === 'traceability_applied') || ($reverse && $last?->action === 'traceability_reversed')) {
@@ -96,6 +102,7 @@ class InvestmentTerritorialTraceability
$values = $update['before'];
$values['version_no'] = $update['after']['version_no'] + 1;
DB::table('cluster_projects')->where('id', $update['id'])->update($values);
$before[] = ['id' => $update['id'], 'before' => $update['after'], 'after' => $values];
}
} else {
foreach ($plan['guards'] as $guard) {
@@ -111,7 +118,7 @@ class InvestmentTerritorialTraceability
}
DB::table('audit_events')->insert(['source_system' => 'territory-traceability-v2', 'entity_type' => 'territorial_sources', 'actor_user_id' => $actor,
'action' => $reverse ? 'traceability_reversed' : 'traceability_applied', 'data_classification' => 'internal', 'occurred_at' => DB::raw('clock_timestamp()'),
'after_data' => json_encode(['plan_hash' => $hash, 'rows' => $plan['rows'], 'updates' => $before, 'no_new_geography' => true, 'official_policy' => 'accepted_without_revalidation'], JSON_THROW_ON_ERROR)]);
'after_data' => json_encode(['plan_hash' => $hash, 'rows' => $plan['rows'], 'updates' => $before, 'reason' => $reverse ? trim($reason) : null, 'no_new_geography' => true, 'official_policy' => 'accepted_without_revalidation'], JSON_THROW_ON_ERROR)]);
return ['replayed' => false, 'changed' => $reverse ? count($applied['updates']) : count($before), 'associations' => count($plan['rows'])];
});
@@ -44,3 +44,25 @@ Provas privadas em `storage/app/private/investmoz-v2`: `territory-activation-pla
**Preparado:** código, pré-visualização e aplicação autenticada. **Validado em QA:** aplicação, reversão, permissões e apresentação. **Aplicado na base ativa:** nenhuma alteração de dados deste incremento; ação autenticada do responsável permanece por executar.
Continuam separadas: questões jurídicas específicas de âmbito/versão/aplicabilidade ainda não resolvidas; diferença de cobertura do inventário; ClamD real; primeiro pedido real no Balcão. A aceitação da proveniência oficial não foi reaberta e nenhuma dessas pendências foi concluída pelos testes territoriais.
## Confirmação após as ações autenticadas
Leitura `READ ONLY` de `investemoz:5432` em 09/10/2026 às 00:10:09 UTC (02:10:09 local). A auditoria regista `traceability_applied` às 02:06:31 e `traceability_reversed` às 02:06:47 locais, ambas pelo utilizador 13 e para o mesmo hash do plano preparado. **Estado atual: incremento revertido; referências propostas não permanecem aplicadas.**
As 13 alterações registadas no evento de aplicação correspondem exatamente aos valores propostos. Após reversão, os 13 pares de referências regressaram aos valores anteriores e as versões aumentaram duas unidades, preservando o histórico. Estados de validação e tipos de associação mantiveram-se; os 46 registos territoriais, as fontes legais, a hierarquia e a geometria também foram preservados. A projeção de fontes deste plano deixa de estar ativa após reversão.
Evidência privada: `territory-post-action-confirmation.json`, incluindo identificação das duas ações e comparação por associação. Esta confirmação não realizou escritas na base ativa. Para reaplicar, é necessário preparar uma nova pré-visualização com as versões atuais e provas correspondentes; não repetir o plano revertido nem a aplicação automaticamente.
## Diagnóstico da reversão e novo plano
9 de outubro de 2026. O evento antigo de reversão identifica a conta 13, a ação e o plano, mas **não contém motivo**. Não é possível inferir a intenção humana. No código, `traceability_reversed` é registado quando a ação explícita de reversão é executada; uma falha transacional não gera esse evento. A comparação anterior comprovou que aplicação e restituição funcionaram corretamente, sem perda de validações.
Foi corrigida uma fragilidade de interação: o mesmo botão mudava de aplicar para reverter após sucesso. A reversão passa a exigir um motivo preenchido e a confirmação expressa de restituição das referências, verificados também no servidor. O motivo e os valores restituídos ficam no novo evento de auditoria. O histórico antigo permanece intacto e apresenta «motivo não registado»; não foi preenchido retroativamente. Um plano revertido aparece como tal e o seu botão fica desativado, exigindo novo plano.
**Nova pré-visualização:** mesma página e ação `Aplicar rastreabilidade territorial V2`; destino `investemoz:5432`, 112 entradas e 13 atualizações, agora com guardas da versão **3** das associações. Hash privado do plano: `ec11b9e400b7b127fbc8d42a2afe5e4d2c8a3919216a5d7439fb38b0751c57c6`. Não cria relações ou geometrias nem reabre a aceitação oficial.
**Provas atualizadas:** backup/restauro de 288 tabelas e cinco ficheiros; cópia `desk_restore_20261009_001345_test:55441`. Ensaio aplicou 13 referências, confirmou idempotência e reverteu 13, preservando validações e onze tabelas relacionadas. A prova nova substitui a anterior para esta aplicação e expira em **10/10/2026 às 02:13:54 locais**. Os ficheiros do plano e da prova anteriores foram preservados privadamente.
Os três testes afetados passaram, com 30 asserções, incluindo recusa de reversão sem motivo/confirmação e conservação do motivo no histórico. TypeScript, formatação/lint, PHPStan e build passaram. As pendências de rastreabilidade, jurídicas específicas, cobertura documental, ClamD e primeiro pedido real permanecem separadas. **Não houve reaplicação automática na base ativa.**
O navegador QA confirmou pré-visualização, aplicação, recuperação e reversão após preencher motivo e confirmação; o botão de reversão estava inicialmente desativado. Sem erros de JavaScript. Leitura ativa final confirmou todas as guardas atuais do novo plano e apenas os dois eventos históricos, sendo o último a reversão.
+8
View File
@@ -163,3 +163,11 @@ Propostas e [relatório territorial](qualidade-territorial.md) corrigidos para d
| QA | 56 testes PHP/884 asserções, quatro testes de reconciliação, TypeScript, lint, PHPStan, build e percurso autenticado no navegador | Zero eventos deste incremento na leitura ativa final; dados territoriais inalterados |
Restam pontes de rastreabilidade e seis correspondências oportunidade–âncora/cluster sem fundamento explícito recuperado; não se retirou a aceitação existente. Questões jurídicas específicas, diferença de cobertura do inventário, ClamD real e primeiro pedido real permanecem separadas.
### Confirmação ativa — aplicação seguida de reversão
Leitura `READ ONLY` em 09/10/2026 às 02:10:09 locais: auditoria confirma aplicação às 02:06:31 e reversão às 02:06:47, pelo utilizador 13, ambas para o plano preparado. As 13 referências foram aplicadas conforme previsto e depois restituídas, preservando validações, tipos de associação, versões monotónicas e histórico. Os 46 registos, fontes legais e geografia mantiveram-se. **Estado atual V2-08/V2-13: incremento revertido na base ativa**, não instalação permanente concluída. Prova privada: `territory-post-action-confirmation.json`. Nova aplicação exige novo plano para as versões atuais; nenhuma escrita foi repetida durante esta confirmação.
### Novo plano após reversão — 9 de outubro
O motivo humano não consta do evento original e não foi inventado. Corrigida a possibilidade de reversão imediata pelo botão que mudava após aplicação: exige agora motivo e confirmação expressa, conservados na auditoria; planos revertidos ficam desativados. Novo plano preparado com as versões 3 atuais das 13 associações, mantendo 112 entradas e validações existentes. Backup/restauro atualizado: 288 tabelas e cinco ficheiros em `desk_restore_20261009_001345_test:55441`; aplicação/idempotência/reversão verificadas na cópia. Três testes afetados/30 asserções, TypeScript, lint, PHPStan e build passaram. [Pré-visualização e prova nova](ativacao-rastreabilidade-territorial.md): válida até 10/10 às 02:13:54 locais. **Não reaplicado na base ativa; ação autenticada permanece por executar.**
@@ -5,6 +5,14 @@ export type TraceabilityPlan = {
code_matches: boolean;
backup_ready: boolean;
applied: boolean;
reversed: boolean;
history: {
id: string;
action: string;
actor_user_id: number;
occurred_at: string;
reason: string | null;
}[];
plan: {
target: { database: string; port: number };
rows: {
@@ -42,6 +50,8 @@ export default function TerritorialTraceabilityPlan({
hash: preview.hash,
operation: 'traceability',
reverse: false,
reason: '',
confirm_reverse: false,
});
return (
<section className="space-y-4 rounded border p-5">
@@ -68,8 +78,23 @@ export default function TerritorialTraceabilityPlan({
{preview.code_matches
? 'corresponde ao plano'
: 'plano deve ser atualizado'}{' '}
· Aplicação: {preview.applied ? 'aplicada' : 'não aplicada'}
· Aplicação:{' '}
{preview.applied
? 'aplicada'
: preview.reversed
? 'revertida; novo plano necessário'
: 'não aplicada'}
</p>
<details>
<summary>Histórico de aplicação e reversão</summary>
{preview.history.map((event) => (
<p key={event.id}>
{event.occurred_at} · {event.action} · conta{' '}
{event.actor_user_id} · Motivo:{' '}
{event.reason ?? 'não registado no evento original'}
</p>
))}
</details>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -77,17 +102,55 @@ export default function TerritorialTraceabilityPlan({
hash: preview.hash,
operation: 'traceability',
reverse: preview.applied,
reason: form.data.reason,
confirm_reverse: form.data.confirm_reverse,
}));
form.post('/management/relationship-repair', {
preserveScroll: true,
});
}}
>
{preview.applied && (
<fieldset className="space-y-3 rounded border border-red-600 p-4">
<legend>
Reversão — restitui as referências anteriores
</legend>
<label className="block">
Motivo da reversão
<textarea
className="block w-full rounded border p-2"
value={form.data.reason}
maxLength={1000}
required
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</label>
<label className="block">
<input
type="checkbox"
checked={form.data.confirm_reverse}
onChange={(event) =>
form.setData(
'confirm_reverse',
event.target.checked,
)
}
/>{' '}
Confirmo que pretendo reverter este incremento
</label>
</fieldset>
)}
<button
disabled={
form.processing ||
!preview.backup_ready ||
!preview.code_matches
!preview.code_matches ||
preview.reversed ||
(preview.applied &&
(!form.data.confirm_reverse ||
!form.data.reason.trim()))
}
className="rounded bg-emerald-800 px-4 py-3 text-white disabled:opacity-50"
>
@@ -70,7 +70,9 @@ class InvestmentTerritorialTraceabilityTest extends TestCase
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'validation_status' => $f['row']->validation_status, 'source_reference' => 'QA synthetic source']);
$f['dossier']->update(['publication_status' => 'draft']);
$this->assertSame([], $service->sources('economic_clusters', $f['cluster']->id));
$this->postJson('/management/relationship-repair', [...$payload, 'reverse' => true])->assertOk()->assertJsonPath('changed', 1);
$this->postJson('/management/relationship-repair', [...$payload, 'reverse' => true])->assertUnprocessable()->assertJsonValidationErrors(['reason', 'confirm_reverse']);
$this->postJson('/management/relationship-repair', [...$payload, 'reverse' => true, 'reason' => 'QA reversal preservation test', 'confirm_reverse' => true])->assertOk()->assertJsonPath('changed', 1);
$this->assertSame('QA reversal preservation test', app(InvestmentTerritorialTraceability::class)->preview()['history'][0]['reason']);
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'version_no' => $f['row']->version_no + 2, 'source_reference' => null, 'evidence_reference' => null, 'validation_status' => $f['row']->validation_status]);
$this->assertSame([], $service->sources('economic_clusters', $f['cluster']->id));
$this->assertSame(2, DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->count());
+1 -1
View File
@@ -53,7 +53,7 @@ $proof['dossiers'] = $pdo->query('SELECT id,project_id,cluster_id,title,source,c
$proof['legal_sources'] = $pdo->query('SELECT id,title,url,diploma,article,competent_entity,validation_status,validated_at,version_no,valid_from,valid_until FROM investment_legal_sources ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$proof['geo_sources'] = $pdo->query('SELECT id,name,responsible_entity,reference,checksum,source_version,status,approval_reference FROM geo_sources ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$proof['geo_profiles'] = $pdo->query('SELECT geo_location_id,source_id,status,valid_from,valid_to FROM geo_territory_profiles ORDER BY geo_location_id')->fetchAll(PDO::FETCH_ASSOC);
$proof['traceability_events'] = $pdo->query("SELECT id,action,occurred_at FROM audit_events WHERE source_system='territory-traceability-v2' ORDER BY occurred_at,id")->fetchAll(PDO::FETCH_ASSOC);
$proof['traceability_events'] = $pdo->query("SELECT id,action,occurred_at,actor_user_id,after_data FROM audit_events WHERE source_system='territory-traceability-v2' ORDER BY occurred_at,id")->fetchAll(PDO::FETCH_ASSOC);
$pdo->exec('ROLLBACK');
file_put_contents(dirname(__DIR__).'/storage/app/private/investmoz-v2/territory-audit.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode(['identity' => $proof['identity'], 'records' => count($proof['records']), 'territories' => count($proof['geography']), 'geometries' => count($proof['geometries'])], JSON_THROW_ON_ERROR).PHP_EOL;
+1 -1
View File
@@ -56,7 +56,7 @@ if ($applied['changed'] !== count($plan['updates']) || ! $replayed['replayed'] |
throw new RuntimeException('Application/idempotence failed.');
}
$sources = $service->sources('economic_clusters', $plan['rows'][array_search('economic_clusters', array_column($plan['rows'], 'kind'))]['record_id']);
$reversed = $service->apply($qaHash, $actor, true);
$reversed = $service->apply($qaHash, $actor, true, 'Ensaio de reversão na cópia QA restaurada.');
$after = $snapshot();
foreach ($after as $index => $row) {
$expected = $before[$index];
@@ -121,6 +121,21 @@ const base = 'http://127.0.0.1:18093';
/citação recuperada; ponte ao exemplar oficial por recuperar/,
);
await page.goto(base + '/management/relationship-repair');
assert.equal(
await page
.getByRole('button', {
name: 'Reverter este incremento de rastreabilidade',
exact: true,
})
.isEnabled(),
false,
);
await page
.getByLabel('Motivo da reversão')
.fill('Ensaio consciente de reversão no navegador QA.');
await page
.getByLabel('Confirmo que pretendo reverter este incremento')
.check();
const reversal = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&