Exige motivo na reversão territorial e prepara novo plano
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user