diff --git a/.gitignore b/.gitignore
index 5512103..c70e3eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,6 @@ Thumbs.db
/storage/framework/budget-demo/
/storage/framework/budget-proof/
/storage/framework/payables/
+/packages/purchasing/.update.lock
+/packages/purchasing/history/
+/packages/purchasing/current.json
diff --git a/app/Modules/Identity/Http/ModuleRecordsController.php b/app/Modules/Identity/Http/ModuleRecordsController.php
new file mode 100644
index 0000000..dd354da
--- /dev/null
+++ b/app/Modules/Identity/Http/ModuleRecordsController.php
@@ -0,0 +1,55 @@
+identifiers();$out=[];
+ $permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
+ foreach(ModuleRecords::AREAS as $area){
+ $unit=str_ends_with($area,'_drafts')?$context->unitId():null;
+ if(str_ends_with($area,'_drafts')&&!$unit)continue;
+ if(!$permissions->has($context->userId(),$s['tenant_id'],$s['company_id'],"$area.view",$unit))continue;
+ $rows=DB::table('a_'.$area)->where($s)->where('unit_id',$unit)->orderBy('id')->get(['id','name','version']);
+ foreach($rows as $row){
+ $row->modules=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
+ ->whereRaw("details::jsonb->>'area' = ?",[$area])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$row->id])
+ ->orderByDesc('id')->get(['details','action'])->unique(fn($a)=>json_decode($a->details,true)['module_id'])
+ ->filter(fn($a)=>$a->action==='module.record.associated')->map(fn($a)=>json_decode($a->details,true)['module_id'])->values()->all();
+ }
+ $out[$area]=$rows;
+ }
+ return response()->json(['records'=>$out,'modules'=>app(ModuleNavigation::class)->available($context),
+ 'notice'=>'Registos sem associação explícita permanecem aqui visíveis pelos direitos originais. Associar não altera o registo, a origem ou as permissões.'])->header('Cache-Control','no-store');
+ }
+ public function associate(Request $r,CompanyContext $context,Permissions $permissions)
+ {
+ $d=$r->validate(['area'=>'required|in:'.implode(',',ModuleRecords::AREAS),'id'=>'required|integer|min:1','version'=>'required|integer|min:1','module_id'=>'required|string','reason'=>'required|string|min:3|max:500','operation'=>'sometimes|in:associate,remove']);
+ abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);
+ $s=$context->identifiers();$unit=str_ends_with($d['area'],'_drafts')?$context->unitId():null;
+ abort_if(str_ends_with($d['area'],'_drafts')&&!$unit,403);
+ DB::transaction(function()use($d,$s,$unit,$context,$permissions){
+ DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
+ $permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
+ $permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.view',$unit);
+ $permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.edit',$unit);
+ abort_unless(in_array($d['module_id'],array_column(app(ModuleNavigation::class)->available($context),'id'),true),403);
+ $row=DB::table('a_'.$d['area'])->where($s)->where('unit_id',$unit)->where('id',$d['id'])->first(['id','version']);
+ abort_unless($row,403);
+ abort_unless((int)$row->version===(int)$d['version'],409,'O registo mudou. Reabra a consulta antes de decidir a associação.');
+ $previous=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
+ ->whereRaw("details::jsonb->>'area' = ?",[$d['area']])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$d['id']])->whereRaw("details::jsonb->>'module_id' = ?",[$d['module_id']])->orderByDesc('id')->value('action');
+ $remove=($d['operation']??'associate')==='remove';
+ if(($remove&&$previous==='module.record.associated')||(!$remove&&$previous!=='module.record.associated'))
+ ModuleRecords::associate($d['area'],$d['id'],$context->userId(),$s+['unit_id'=>$unit],$d['module_id'],$d['reason'],$remove,(int)$row->version);
+ });
+ return response()->json(['ok'=>true]);
+ }
+}
diff --git a/app/Modules/Identity/Http/RequireCompanyContext.php b/app/Modules/Identity/Http/RequireCompanyContext.php
index 799812c..a229379 100644
--- a/app/Modules/Identity/Http/RequireCompanyContext.php
+++ b/app/Modules/Identity/Http/RequireCompanyContext.php
@@ -25,7 +25,13 @@ final class RequireCompanyContext
abort_unless(is_string($token) && hash_equals($selection['token'] ?? '', $token), 409, 'O contexto mudou. Reabra o formulário antes de guardar.');
}
- return $next($request);
+ app(\App\Shared\Context\RequireModuleContext::class)->check($request, $this->context);
+ $response = $next($request);
+ if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments'], true) && $response->isSuccessful()) {
+ $bar = '';
+ $response->setContent(preg_replace('/(
]*>)/i', '$1'.$bar, $response->getContent(), 1));
+ }
+ return $response;
} finally {
$this->context->clear();
}
diff --git a/app/Modules/Identity/Services/Audit.php b/app/Modules/Identity/Services/Audit.php
index d58cca1..d732512 100644
--- a/app/Modules/Identity/Services/Audit.php
+++ b/app/Modules/Identity/Services/Audit.php
@@ -9,7 +9,7 @@ final class Audit
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
{
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
- $safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id']));
+ $safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id', 'module_id', 'previous_module_id']));
DB::table('identity_audit')->insert([
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
diff --git a/app/Modules/Identity/Services/IdentityWorkflow.php b/app/Modules/Identity/Services/IdentityWorkflow.php
index f53714e..581bc65 100644
--- a/app/Modules/Identity/Services/IdentityWorkflow.php
+++ b/app/Modules/Identity/Services/IdentityWorkflow.php
@@ -43,7 +43,15 @@ final class IdentityWorkflow
$grants = DB::table('permission_grants')->where('user_id', $user->id)->where('company_id', $company->id)
->get(['permission', 'unit_id'])->map(fn ($grant) => ['permission' => $grant->permission, 'unit_id' => $grant->unit_id ? (int) $grant->unit_id : null])->all();
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
- 'token' => $selected['token'], 'grants' => $grants];
+ 'token' => $selected['token'], 'grants' => $grants,
+ 'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context),
+ 'module_id' => $selected['module_id'] ?? null];
+ if ($result['context']['module_id'] && !in_array($result['context']['module_id'], array_column($result['context']['modules'], 'id'), true)) {
+ $result['context']['module_id'] = null;
+ $result['context']['token'] = bin2hex(random_bytes(24));
+ $request->session()->put('company_context', array_replace($selected, ['module_id'=>null, 'token'=>$result['context']['token']]));
+ $result['notice'] = 'O acesso ao módulo mudou. Seleccione um módulo autorizado.';
+ }
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
if ($exception->getStatusCode() !== 403) { throw $exception; }
$request->session()->forget('company_context');
@@ -54,14 +62,26 @@ final class IdentityWorkflow
public function select(Request $request): array
{
- $data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1']);
+ $data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1', 'module_id'=>'nullable|string|max:40']);
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
// Selection is explicit, never silently carries the old unit into another company.
$this->context->selectFor($request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
- $request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'],
- 'unit_id' => $unit, 'token' => bin2hex(random_bytes(24))]);
- Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
- return $this->snapshot($request);
+ $module = $data['module_id'] ?? null;
+ abort_if($module !== null && !in_array($module, array_column(app(\App\Shared\Context\ModuleNavigation::class)->available($this->context), 'id'), true), 403, 'Módulo não autorizado ou não configurado.');
+ $previous = $request->session()->get('company_context.module_id');
+ $oldSelection=$request->session()->get('company_context');
+ try {
+ return DB::transaction(function()use($request,$data,$unit,$module,$previous){
+ $request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'],
+ 'unit_id' => $unit, 'module_id'=>$module, 'token' => bin2hex(random_bytes(24))]);
+ Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit, details:['module_id'=>$module, 'previous_module_id'=>$previous]);
+ return $this->snapshot($request);
+ });
+ } catch (\Throwable $failure) {
+ if($oldSelection===null)$request->session()->forget('company_context');
+ else $request->session()->put('company_context',$oldSelection);
+ throw $failure;
+ }
}
public function renameCompany(Request $request, int $company): void
diff --git a/app/Modules/Services/CatalogueAccounting.php b/app/Modules/Services/CatalogueAccounting.php
index a67e446..5a922eb 100644
--- a/app/Modules/Services/CatalogueAccounting.php
+++ b/app/Modules/Services/CatalogueAccounting.php
@@ -36,7 +36,7 @@ final class CatalogueAccounting
public function listing(int $actor): array
{
$scope=$this->scope($actor); $rows=$this->query($scope)->orderBy('id')->get()->map(fn($r)=>$this->decode($r))->all();
- $services=$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'services.view') ? DB::table('a_services')->where($scope)->orderBy('name')->get(['id','version','name'])->all() : [];
+ $services=$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'services.view') ? \App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->orderBy('name')->get(['id','version','name'])->all() : [];
return ['notice'=>self::NOTICE,'reference'=>self::REFERENCE,'proposals'=>$rows,'services'=>$services];
}
private function command(int $actor,string $action,array $request,string $key,callable $write): array
@@ -73,7 +73,7 @@ final class CatalogueAccounting
if(isset($data['service_id'])) {
$data['service_id']=(int)$data['service_id'];
$this->permissions->require($this->context->userId(),$scope['tenant_id'],$scope['company_id'],'services.view');
- abort_unless(DB::table('a_services')->where($scope)->where('id',$data['service_id'])->exists(),403,'Serviço fora da empresa.');
+ abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$data['service_id'])->exists(),403,'Serviço fora da empresa.');
}
if($kind==='account') {
$data['movable']=(bool)$data['movable'];
@@ -166,7 +166,7 @@ final class CatalogueAccounting
}
private function resolveInternal(int $service,string $date,array $scope): array
{
- $s=DB::table('a_services')->where($scope)->where('id',$service)->first(); abort_unless($s,403,'Serviço fora da empresa.');
+ $s=\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$service)->first(); abort_unless($s,403,'Serviço fora da empresa.');
$base=['service'=>['id'=>$service,'version'=>(int)$s->version,'name'=>$s->name],'reference_date'=>$date,'notice'=>self::NOTICE];
$override=$this->select($scope,'service_override','service:'.$service,$date);
$link=$this->select($scope,'service_link','service:'.$service,$date);
@@ -181,7 +181,7 @@ final class CatalogueAccounting
public function resolve(int $service,?string $date,int $actor): array
{
$scope=$this->scope($actor); $this->permissions->require($actor,$scope['tenant_id'],$scope['company_id'],'services.view');
- abort_unless(DB::table('a_services')->where($scope)->where('id',$service)->exists(),403);
+ abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$service)->exists(),403);
if(!$date)return ['state'=>'pending','pending'=>['Data de referência explícita em falta; nenhuma data foi presumida.']]; $this->date($date);
$read=function()use($service,$date,$scope){$result=$this->resolveInternal($service,$date,$scope);return $result+['revision'=>hash_hmac('sha256',json_encode($result,JSON_THROW_ON_ERROR),(string)config('app.key'))];};
if(DB::transactionLevel())return $read();
diff --git a/app/Modules/Services/DocumentPreparation.php b/app/Modules/Services/DocumentPreparation.php
index e5f0356..6da0aa3 100644
--- a/app/Modules/Services/DocumentPreparation.php
+++ b/app/Modules/Services/DocumentPreparation.php
@@ -37,7 +37,7 @@ final class DocumentPreparation
{
if ($kind === 'payment') { return $id ? $this->query($kind, $scope)->where('id', $id)->first() : null; }
$table = $kind === 'issuer' ? 'companies' : ($kind === 'client' ? 'a_clients' : 'a_'.$kind);
- $query = DB::table($table)->where('tenant_id', $scope['tenant_id'])->where($kind === 'issuer' ? 'id' : 'company_id', $scope['company_id'])->where('id', $id);
+ $query = \App\Shared\Context\ModuleRecords::query($table)->where('tenant_id', $scope['tenant_id'])->where($kind === 'issuer' ? 'id' : 'company_id', $scope['company_id'])->where('id', $id);
if ($scope['unit_id']) { $query->where('unit_id', $scope['unit_id']); }
$target = $query->first(); abort_unless($target, 403, 'Registo fora do âmbito autorizado.'); return $target;
}
@@ -47,7 +47,7 @@ final class DocumentPreparation
$scope = $this->scope($kind, $actor, 'view');
if ($kind === 'payment') { return $this->query($kind, $scope)->orderBy('id')->get()->map(fn ($row) => $this->decode($row))->all(); }
if ($kind === 'issuer') { return [['id' => $scope['company_id'], 'name' => DB::table('companies')->where('id', $scope['company_id'])->value('name')]]; }
- if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); return DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->orderBy('name')->get(['id','name'])->all(); }
+ if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); return \App\Shared\Context\ModuleRecords::query('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->orderBy('name')->get(['id','name'])->all(); }
return $this->workflow->list($kind, $actor);
}
@@ -124,7 +124,7 @@ final class DocumentPreparation
public function snapshot(int $draft, int $actor): ?array
{
$scope = $this->context->identifiers(); $scope['unit_id'] = $this->context->unitId();
- $billing = DB::table('a_billing_drafts')->where($scope)->where('id', $draft)->firstOrFail();
+ $billing = \App\Shared\Context\ModuleRecords::query('a_billing_drafts')->where($scope)->where('id', $draft)->firstOrFail();
$rows = DB::table('doc_records')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where(function ($q) use ($billing, $draft) {
$q->where('kind', 'issuer')->orWhere(fn ($q) => $q->where('kind', 'client')->where('record_id', $billing->client_id))->orWhere(fn ($q) => $q->where('kind', 'billing_drafts')->where('record_id', $draft));
})->get();
@@ -148,7 +148,7 @@ final class DocumentPreparation
$row = $this->query('service_drafts', $scope)->where('record_id', $source)->first();
if (! $row) { return; }
$data = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
- if ((int) $data['draft_version'] !== (int) DB::table('a_service_drafts')->where('id', $source)->value('version')) { return; }
+ if ((int) $data['draft_version'] !== (int) \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id', $source)->value('version')) { return; }
$data['draft_version'] = 1; $data['source_preparation'] = ['id' => $row->id, 'version' => $row->version];
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
$id = DB::table('doc_records')->insertGetId($scope + ['kind' => 'billing_drafts', 'record_id' => $billing, 'version' => 1, 'data' => $encoded, 'created_at' => now(), 'updated_at' => now()]);
diff --git a/app/Modules/Services/DraftValues.php b/app/Modules/Services/DraftValues.php
index 4446688..e73c97e 100644
--- a/app/Modules/Services/DraftValues.php
+++ b/app/Modules/Services/DraftValues.php
@@ -23,7 +23,7 @@ final class DraftValues
{
$result = [];
foreach ($lines as $index => $line) {
- $service = DB::table('a_services')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $line['service_id'])->first();
+ $service = \App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $line['service_id'])->first();
abort_unless($service, 403, 'Serviço fora da empresa autorizada.');
$result[] = ['service_id' => (int) $service->id, 'service_name' => $service->name,
'description' => $line['description'] ?? null,
diff --git a/app/Modules/Services/Http/ServiceController.php b/app/Modules/Services/Http/ServiceController.php
index 6174e6a..ce55103 100644
--- a/app/Modules/Services/Http/ServiceController.php
+++ b/app/Modules/Services/Http/ServiceController.php
@@ -10,7 +10,11 @@ use Illuminate\Http\Request;
final class ServiceController extends Controller
{
- public function index(Request $request, string $area, ServiceWorkflow $workflow) { return response()->json(['records' => $workflow->list($area, $request->user()->id)]); }
+ public function index(Request $request, string $area, ServiceWorkflow $workflow) {
+ $records=$workflow->list($area, $request->user()->id);
+ return response()->json(['records'=>$records,'module_id'=>\App\Shared\Context\ModuleRecords::active(),
+ 'scope_notice'=>'Apenas registos explicitamente associados ao módulo activo. Registos anteriores sem associação não foram eliminados: consulte Associações de módulos nas opções gerais.']);
+ }
public function show(Request $request, string $area, int $id, ServiceWorkflow $workflow)
{
$record = $workflow->show($area, $id, $request->user()->id);
diff --git a/app/Modules/Services/InternalConfirmation.php b/app/Modules/Services/InternalConfirmation.php
index f31cf0b..d189006 100644
--- a/app/Modules/Services/InternalConfirmation.php
+++ b/app/Modules/Services/InternalConfirmation.php
@@ -17,6 +17,7 @@ final class InternalConfirmation
public function show(int $draft, int $actor): ?array
{
$scope = $this->workflow->scope('billing_drafts', $actor, 'view');
+ abort_unless(\App\Shared\Context\ModuleRecords::query('a_billing_drafts')->where($scope)->where('id',$draft)->exists(),403,'Registo fora do módulo ou do âmbito autorizado.');
$row = DB::table('b_internal_confirmations')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('billing_draft_id', $draft)->first();
if (! $row) { return null; }
$history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $row->id)->value('id');
@@ -43,7 +44,7 @@ final class InternalConfirmation
abort_unless((int) $old->actor_id === $actor && hash_equals($old->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.');
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
}
- $billing = DB::table('a_billing_drafts')->where('id', $draft)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->lockForUpdate()->first();
+ $billing = \App\Shared\Context\ModuleRecords::query('a_billing_drafts')->where('id', $draft)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->lockForUpdate()->first();
abort_unless($billing, 403, 'Rascunho fora do âmbito autorizado.');
abort_unless((int) $billing->version === $version, 409, 'A versão mudou; reabra o rascunho.');
$existing = DB::table('b_internal_confirmations')->where('billing_draft_id', $draft)->first();
@@ -53,16 +54,16 @@ final class InternalConfirmation
$result = ['id' => $existing->id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$existing->id, 'event_id' => $event];
} else {
$data = json_decode($billing->data, true, 32, JSON_THROW_ON_ERROR);
- $source = DB::table('a_service_drafts')->where('id', $billing->source_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->first();
+ $source = \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id', $billing->source_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->first();
abort_unless($source && (int) $source->version === (int) $data['source_version'], 409, 'A prestação mudou; reveja e actualize explicitamente o rascunho antes de confirmar.');
- $client = DB::table('a_clients')->where('id', $billing->client_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->first();
+ $client = \App\Shared\Context\ModuleRecords::query('a_clients')->where('id', $billing->client_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->first();
abort_unless($client && trim($client->name) !== '' && trim($billing->name) !== '', 422, 'Complete o cliente e a designação do percurso.');
abort_unless((int) $source->client_id === (int) $billing->client_id, 422, 'O cliente diverge da prestação de origem.');
$data['lines'] = DraftValues::lines($data['lines'], $scope);
foreach ($data['lines'] as $line) { abort_if($line['quantity'] === null || $line['unit_price'] === null || trim($line['service_name']) === '', 422, 'Complete quantidade, preço e serviço de todas as linhas.'); }
$pending = DraftValues::pending($data) + ['accounts_pending' => 'Contas e classificação por validar; contabilização bloqueada.', 'recognition_pending' => 'Momento e regras de reconhecimento por validar; contabilização bloqueada.'];
$catalogue = [];
- foreach (array_unique(array_column($data['lines'], 'service_id')) as $id) { $service = DB::table('a_services')->where('id', $id)->first(); $catalogue[] = $this->record($service); }
+ foreach (array_unique(array_column($data['lines'], 'service_id')) as $id) { $service = \App\Shared\Context\ModuleRecords::query('a_services')->where('id', $id)->first(); $catalogue[] = $this->record($service); }
$data['client_name'] = $client->name;
$snapshot = ['label' => self::LABEL, 'scope' => $scope, 'billing_draft' => $this->record($billing), 'service_draft' => $this->record($source),
'client' => $this->record($client), 'services' => $catalogue, 'data' => $data, 'base_amount' => DraftValues::base($data),
diff --git a/app/Modules/Services/LocalFiscalActivation.php b/app/Modules/Services/LocalFiscalActivation.php
index 7fd7313..40bc2ef 100644
--- a/app/Modules/Services/LocalFiscalActivation.php
+++ b/app/Modules/Services/LocalFiscalActivation.php
@@ -30,7 +30,7 @@ final class LocalFiscalActivation
foreach(['service_id','service_version'] as $key) abort_unless((int)$d[$key]===(int)$in[$key],409,'Serviço/versão diferente.');
abort_unless($d['valid_from']===$in['valid_from'] && ($d['valid_until'] ?? null)===($in['valid_until'] ?? null),409,'Vigência diferente.');
abort_unless($d['currency']==='MZN' && $d['price_mode']==='added' && $d['rate_percent']==='16' && $d['treatment']==='domestic_normal',409,'Tratamento diferente do recorte aprovado.');
- abort_unless(DB::table('a_services')->where('id',$in['service_id'])->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])->where('version',$in['service_version'])->exists(),409,'Serviço desactualizado.');
+ abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where('id',$in['service_id'])->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])->where('version',$in['service_version'])->exists(),409,'Serviço desactualizado.');
abort_unless(DB::table('sf_decisions')->where('proposal_id',$p->id)->where('decision','approved')->exists(),409,'Aprovação exacta em falta.');
abort_if(DB::table('sf_proposals')->where($scope)->where('kind','profile')->where('identifier',$p->identifier)->where('version','>',$p->version)->whereExists(fn($q)=>$q->selectRaw('1')->from('sf_decisions')->whereColumn('sf_decisions.proposal_id','sf_proposals.id')->where('decision','approved'))->exists(),409,'Outra versão já foi aprovada; reveja a operação.');
$profile=DB::table('service_calculation_profiles')->where($scope)->where('id',$p->profile_id)->where('identifier',$p->identifier)->where('version',$p->version)->first();
diff --git a/app/Modules/Services/ServiceCalculation.php b/app/Modules/Services/ServiceCalculation.php
index 6981202..654b6dd 100644
--- a/app/Modules/Services/ServiceCalculation.php
+++ b/app/Modules/Services/ServiceCalculation.php
@@ -68,7 +68,7 @@ final class ServiceCalculation
'data' => $b['data'], 'prepared' => $p['data'], 'profile_id' => (int) $profile->id,
'profile_version' => (int) $profile->version, 'profile_identifier' => $profile->identifier,
'profile' => json_decode($profile->definition, true, 32, JSON_THROW_ON_ERROR), 'policy' => self::POLICY,
- 'source_current_version' => (int) DB::table('a_service_drafts')->where('id',$b['source_id'])->where('tenant_id',$b['tenant_id'])->where('company_id',$b['company_id'])->where('unit_id',$b['unit_id'])->value('version')];
+ 'source_current_version' => (int) \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id',$b['source_id'])->where('tenant_id',$b['tenant_id'])->where('company_id',$b['company_id'])->where('unit_id',$b['unit_id'])->value('version')];
}
private function fingerprint(array $input): string { return hash('sha256', json_encode($input, JSON_THROW_ON_ERROR)); }
diff --git a/app/Modules/Services/ServiceFiscal.php b/app/Modules/Services/ServiceFiscal.php
index 0cc2a77..b971ecf 100644
--- a/app/Modules/Services/ServiceFiscal.php
+++ b/app/Modules/Services/ServiceFiscal.php
@@ -69,7 +69,7 @@ final class ServiceFiscal
$version=(int)($last?->version ?? 0)+1;
if ($in['kind']==='profile') {
$d=$this->exact($in['data'],['service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:added','rate_percent'=>'required|in:16','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','treatment'=>'required|in:domestic_normal','conditions'=>'required|string|max:4000','evidence'=>'required|string|max:4000']);
- $service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
+ $service=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'],409,'Serviço/versão incompatível.');
if ($previous) abort_unless((int)$this->data($previous)['service_id']===(int)$d['service_id'],422,'Nova versão não pode trocar a identidade do serviço.');
abort_if(DB::table('sf_proposals')->where($s)->where('kind','profile')->whereRaw("data->>'service_id' = ?",[(string)$d['service_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade existente deste serviço e uma nova versão.');
@@ -126,7 +126,7 @@ final class ServiceFiscal
private function versions(array $b,array $p,array $d): void
{
abort_unless($b['version']===(int)$d['draft_version'] && $p['version']===(int)$d['preparation_version'] && ($p['data']['draft_version'] ?? null)===$b['version'],409,'Rascunho/preparação desactualizados.');
- $current=DB::table('a_service_drafts')->where('id',$b['source_id'])->value('version');
+ $current=\App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id',$b['source_id'])->value('version');
abort_unless((int)$current===(int)$b['data']['source_version'],409,'Origem desactualizada.');
}
private function compatible(object $p,array $s,string $date,array $b,array $prepared,bool $active=true): array
@@ -134,7 +134,7 @@ final class ServiceFiscal
$d=$this->data($p); $line=$b['data']['lines'][0] ?? [];
abort_unless($p->kind==='profile' && $this->approved($p) && (!$active || $this->activated($p)) && $this->latest($p,$s,$date),409,'Perfil sem aprovação/activação exacta ou substituído.');
abort_unless($date!=='' && $d['valid_from']<=$date && (empty($d['valid_until']) || $date<=$d['valid_until']),422,'Vigência não demonstrada.');
- $service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
+ $service=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'] && count($b['data']['lines'])===1 && (int)($line['service_id'] ?? 0)===(int)$d['service_id'] && $b['data']['currency']===$d['currency'] && ($prepared['data']['price_mode'] ?? null)===$d['price_mode'],422,'Serviço, moeda ou modo incompatível.');
return $d;
}
diff --git a/app/Modules/Services/ServiceInvoicing.php b/app/Modules/Services/ServiceInvoicing.php
index d4984a7..a162c91 100644
--- a/app/Modules/Services/ServiceInvoicing.php
+++ b/app/Modules/Services/ServiceInvoicing.php
@@ -101,7 +101,7 @@ final class ServiceInvoicing
if(!ServiceCalculation::isolated()) return ['real_blocked'=>true,'test_enabled'=>false,'notice'=>'Emissão real bloqueada: conformidade ainda não demonstrada.','proposals'=>[],'invoices'=>[]];
TestingDatabaseGuard::assertSafe();
if(!Schema::hasTable('si_invoices')) return ['real_blocked'=>true,'notice'=>'Migração documental ainda não aplicada.','proposals'=>[],'invoices'=>[]];
- return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>DB::table('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
+ return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
}
public function review(int $draft,int $series,int $rule,int $actor): array
{
@@ -167,7 +167,7 @@ final class ServiceInvoicing
$snapshot=$this->snapshot($p,$number,$instant);
$snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes.
$bytes=$this->pdf->render($snapshot);
- $id=DB::table('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]);
+ $id=\App\Shared\Context\ModuleRecords::query('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]);
$event=DB::table('si_events')->insertGetId(['invoice_id'=>$id,'type'=>'test_invoice.captured','payload'=>json_encode(['invoice_id'=>$id,'simulation_only'=>true],JSON_THROW_ON_ERROR),'created_at'=>now()]);
DB::table('si_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_processing']);
Audit::record('service_invoice.test_issued',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'event_id'=>$event,'version'=>1]);
@@ -179,13 +179,13 @@ final class ServiceInvoicing
public function show(int $id,int $actor): array
{
self::guard(); $s=$this->scope($actor,'service_invoices.view');
- $r=DB::table('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
+ $r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
$snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR);
return ['id'=>$r->id,'snapshot'=>$snapshot,'document_lines'=>$snapshot['document_lines'] ?? InvoicePdf::lines($snapshot),'pdf_sha256'=>$r->pdf_sha256,'corrections'=>DB::table('si_correction_requests')->where('invoice_id',$id)->get(['id','reason','actor_id','created_at'])->all()];
}
public function pdf(int $id,int $actor): string
{
- $this->show($id,$actor); $r=DB::table('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']);
+ $this->show($id,$actor); $r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']);
$bytes=base64_decode($r->pdf_base64,true); abort_unless($bytes!==false && hash_equals($r->pdf_sha256,hash('sha256',$bytes)),409,'Integridade do PDF não confirmada.'); return $bytes;
}
public function requestCorrection(int $id,int $actor,array $input): array
diff --git a/app/Modules/Services/ServiceReceivables.php b/app/Modules/Services/ServiceReceivables.php
index 8131710..156fdb2 100644
--- a/app/Modules/Services/ServiceReceivables.php
+++ b/app/Modules/Services/ServiceReceivables.php
@@ -90,7 +90,7 @@ final class ServiceReceivables
$right=$kind==='correction'?'correction_request':'prepare';
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
return $this->command($actor,$right,$key,$d,function($s)use($actor,$d,$kind) {
- $inv=DB::table('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
+ $inv=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
abort_unless($inv,403,'Documento de origem não autorizado.');
$snapshot=json_decode($inv->snapshot,true,32,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['simulation_only']??false)===true && ($snapshot['notice']??'')===self::NOTICE && ($snapshot['document']['data']['currency']??'')==='MZN',422,'Origem sintética compatível necessária.');
@@ -222,7 +222,7 @@ final class ServiceReceivables
return DB::transaction(function()use($s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$records=DB::table('sr_records')->where($s)->orderBy('id')->get();
- return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>DB::table('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
+ return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
});
}
/** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
diff --git a/app/Modules/Services/ServiceWorkflow.php b/app/Modules/Services/ServiceWorkflow.php
index b5e0982..d72d727 100644
--- a/app/Modules/Services/ServiceWorkflow.php
+++ b/app/Modules/Services/ServiceWorkflow.php
@@ -25,7 +25,7 @@ final class ServiceWorkflow
private function query(string $area, array $scope)
{
- return DB::table('a_'.$area)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id']);
+ return \App\Shared\Context\ModuleRecords::query('a_'.$area)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id']);
}
public function list(string $area, int $actor): array
@@ -74,7 +74,9 @@ final class ServiceWorkflow
$scope = $this->scope($area, $actor, $action);
$selectedUnit = $this->context->unitId();
abort_unless((bool) preg_match('/^[a-zA-Z0-9-]{8,100}$/D', $key), 422, 'É necessária uma chave de repetição válida.');
- $hash = hash('sha256', json_encode($this->canonical([$area, $id, $scope, $input]), JSON_THROW_ON_ERROR));
+ $identity=[$area, $id, $scope, $input];
+ if($module=\App\Shared\Context\ModuleRecords::active())$identity[]=$module;
+ $hash = hash('sha256', json_encode($this->canonical($identity), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($area, $id, $actor, $input, $key, $scope, $selectedUnit, $hash, $action): array {
DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selectedUnit);
@@ -99,13 +101,18 @@ final class ServiceWorkflow
$data = $values['data']; $values['data'] = json_encode($data === [] ? new \stdClass() : $data, JSON_THROW_ON_ERROR);
$values += ['version' => $version, 'updated_at' => now()];
if ($id) { $this->query($area, $scope)->where('id', $id)->update($values); }
- else { $id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]); }
+ else {
+ $id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]);
+ if ($module=\App\Shared\Context\ModuleRecords::active()) {
+ \App\Shared\Context\ModuleRecords::associate($area,$id,$actor,$scope,$module,'Origem explícita da criação no módulo activo.',false,$version);
+ }
+ }
$record = $this->query($area, $scope)->where('id', $id)->first();
if ($area === 'billing_drafts' && ! $old) { app(DocumentPreparation::class)->inheritToBilling((int) $record->source_id, $id, $actor, $scope); }
$snapshot = (array) $record; $snapshot['data'] = $data;
$pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : [];
if ($area === 'billing_drafts') {
- $current = DB::table('a_service_drafts')->where('id', $record->source_id)->value('version');
+ $current = \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id', $record->source_id)->value('version');
if ((int) $current !== (int) $data['source_version']) { $pending['source_changed'] = 'A prestação tem uma versão mais recente. Reveja ou actualize explicitamente os dados de origem.'; }
}
$snapshot['pending'] = $pending;
@@ -147,7 +154,7 @@ final class ServiceWorkflow
if (!$old && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
abort_if(\App\Modules\Workshop\WorkshopCommercialState::linked('service_drafts', (int)$sourceId),409,'Prestação de oficina já convertida: não pode gerar um segundo rascunho por outro percurso.');
}
- $source = DB::table('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first();
+ $source = \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first();
abort_unless($source, 403, 'Prestação fora da unidade autorizada.');
$reload = ! $old || ($input['reload_source'] ?? false);
if ($reload) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'service_drafts.view', $scope['unit_id']); }
@@ -161,7 +168,7 @@ final class ServiceWorkflow
} else {
$clientId = $input['client_id']; $lines = $input['lines']; $currency = $input['currency'] ?? null;
}
- $client = DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $clientId)->first();
+ $client = \App\Shared\Context\ModuleRecords::query('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $clientId)->first();
abort_unless($client, 403, 'Cliente fora da empresa autorizada.');
$data = ['description' => $input['description'] ?? ($base['description'] ?? ($oldData['description'] ?? null)), 'currency' => $currency,
'lines' => DraftValues::lines($lines, $scope), 'client_name' => $client->name, 'tax_status' => 'pending', 'accounting_status' => 'not_processed'];
diff --git a/app/Modules/Workshop/WorkshopBilling.php b/app/Modules/Workshop/WorkshopBilling.php
index 4a01b89..276b784 100644
--- a/app/Modules/Workshop/WorkshopBilling.php
+++ b/app/Modules/Workshop/WorkshopBilling.php
@@ -35,13 +35,13 @@ final class WorkshopBilling {
}
private function compose(int $order,array $input,array $s,bool $allowClaim=false): array {
$o=$this->order($order,$s);abort_unless($input['client_id']===$o->client_id&&$input['currency']==='MZN',409,'Cliente ou moeda incompatível.');
- $client=DB::table('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409);
+ $client=\App\Shared\Context\ModuleRecords::query('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409);
abort_unless(count($input['lines'])>0,422);$seen=[];$lines=[];
foreach($input['lines'] as $line){
$d=$this->exact($line,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120']);
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_if(isset($seen[$e->origin]),409,'Origem repetida.');$seen[$e->origin]=true;
abort_if(!$allowClaim&&DB::table('wb_claims')->where(['order_id'=>$order,'origin'=>$e->origin])->exists(),409,'Origem já utilizada comercialmente.');
- $service=DB::table('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
+ $service=\App\Shared\Context\ModuleRecords::query('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$price=DraftValues::decimal($d['price'],'price');abort_if($price===null,422,'Preço desconhecido não permite gerar.');
$quantity=$e->kind==='time'?Exact::text(Exact::hours($e->minutes)):$e->quantity;$quantity=DraftValues::decimal($quantity,'quantity',true);
$lines[]=['entry_id'=>$e->id,'entry_version'=>$e->version,'origin'=>$e->origin,'task_id'=>$e->task_id,'source_kind'=>$e->kind,'source_quantity'=>$e->kind==='time'?(string)$e->minutes:$e->quantity,'unit'=>$e->kind==='time'?'H':$e->data['unit'],'service_id'=>$service->id,'service_version'=>$service->version,'quantity'=>$quantity,'unit_price'=>$price,'price_source'=>$d['price_source'],'description'=>"Oficina #{$order} · origem #{$e->id} v{$e->version} · ".($e->kind==='time'?'Trabalho efectivo':$e->data['resource'])];
@@ -86,6 +86,6 @@ final class WorkshopBilling {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[]];self::guard();
return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$orders=[];foreach(DB::table('wo_orders')->where($s)->get() as $o){$entries=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->get() as $e){try{$entries[]=$this->source($e->id,$e->version,$o->id);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}}
$proposals=DB::table('wb_proposals')->where('order_id',$o->id)->orderBy('version')->get()->all();foreach($proposals as $p){$p->snapshot=json_decode($p->snapshot,true);$p->decision=DB::table('wb_decisions')->where('proposal_id',$p->id)->first();$p->current=!DB::table('wb_proposals')->where('previous_id',$p->id)->exists();if($p->current){try{$this->recheck($p,$s,true);}catch(\Throwable){$p->current=false;}}}
- $orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
+ $orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>\App\Shared\Context\ModuleRecords::query('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
}
}
diff --git a/app/Modules/Workshop/WorkshopCorrections.php b/app/Modules/Workshop/WorkshopCorrections.php
index 9cebbde..0a570cc 100644
--- a/app/Modules/Workshop/WorkshopCorrections.php
+++ b/app/Modules/Workshop/WorkshopCorrections.php
@@ -24,7 +24,7 @@ final class WorkshopCorrections {
return ['sequence'=>$a?->sequence??0,'service_id'=>$a?->new_service_id??$g->service_draft_id,'billing_id'=>$a?->new_billing_id??$g->billing_draft_id];
}
private function baseline(object $g,array $s): array {
- $c=$this->current($g);$service=DB::table('a_service_drafts')->find($c['service_id']);$bill=DB::table('a_billing_drafts')->find($c['billing_id']);$order=DB::table('wo_orders')->find($g->order_id);
+ $c=$this->current($g);$service=\App\Shared\Context\ModuleRecords::query('a_service_drafts')->find($c['service_id']);$bill=\App\Shared\Context\ModuleRecords::query('a_billing_drafts')->find($c['billing_id']);$order=DB::table('wo_orders')->find($g->order_id);
abort_unless($service&&$bill&&$service->state==='draft'&&$bill->state==='draft'&&$bill->source_id===$service->id&&$bill->client_id===$order->client_id&&$service->client_id===$order->client_id,409,'Destino ou cliente incompatível.');
WorkshopCommercialState::assertCurrent('billing_drafts',$bill->id);
abort_if(DB::table('b_internal_confirmations')->where('billing_draft_id',$bill->id)->exists()||DB::table('a_billing_drafts')->where('source_id',$service->id)->where('id','<>',$bill->id)->exists(),409,'Destino confirmado ou com dependências.');
@@ -32,14 +32,14 @@ final class WorkshopCorrections {
abort_if(DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->where(fn($q)=>$q->where(fn($x)=>$x->where('kind','billing_drafts')->where('record_id',$bill->id))->orWhere(fn($x)=>$x->where('kind','service_drafts')->where('record_id',$service->id)))->exists(),409,'Preparação documental existente: herança exige revisão própria.');
$origin=json_decode(DB::table('wb_proposals')->find($g->proposal_id)->snapshot,true,64,JSON_THROW_ON_ERROR);
foreach($origin['lines'] as $line){$e=DB::table('wo_entries')->where(['id'=>$line['entry_id'],'version'=>$line['entry_version'],'order_id'=>$g->order_id])->first();abort_unless($e&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$e->id)->exists()&&!DB::table('wo_entries')->where('previous_id',$e->task_id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$e->id,'entry_version'=>$e->version,'decision'=>'verified'])->exists(),409,'Origem técnica desactualizada ou não verificada.');}
- $bd=json_decode($bill->data,true);abort_unless($bd['currency']==='MZN',409,'Moeda incompatível.');
- return $c+['service_version'=>$service->version,'billing_version'=>$bill->version,'client_id'=>$bill->client_id,'client_version'=>DB::table('a_clients')->find($bill->client_id)->version,'currency'=>'MZN','current_base_exact'=>DraftValues::base($bd),'original_origins'=>$origin['lines'],'current_lines'=>$bd['lines'],'doc_versions'=>DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->get(['id','version'])->map(fn($r)=>(array)$r)->all()];
+ $client=\App\Shared\Context\ModuleRecords::query('a_clients')->find($bill->client_id)??abort(409,'Associação do cliente ao módulo activo pendente.');$bd=json_decode($bill->data,true);abort_unless($bd['currency']==='MZN',409,'Moeda incompatível.');
+ return $c+['service_version'=>$service->version,'billing_version'=>$bill->version,'client_id'=>$bill->client_id,'client_version'=>$client->version,'currency'=>'MZN','current_base_exact'=>DraftValues::base($bd),'original_origins'=>$origin['lines'],'current_lines'=>$bd['lines'],'doc_versions'=>DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->get(['id','version'])->map(fn($r)=>(array)$r)->all()];
}
private function commercial(array $base,array $input,array $s): array {
abort_unless(count($input)===count($base['original_origins']),422,'Manter todas as origens integrais.');$out=[];
foreach($base['original_origins'] as $i=>$origin){$l=$this->exact($input[$i],['entry_id'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120','description'=>'required|string|min:3|max:500']);
abort_unless($l['entry_id']===$origin['entry_id'],409,'Não pode substituir ou reordenar origens.');
- $service=DB::table('a_services')->where(['id'=>$l['service_id'],'version'=>$l['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
+ $service=\App\Shared\Context\ModuleRecords::query('a_services')->where(['id'=>$l['service_id'],'version'=>$l['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$price=DraftValues::decimal($l['price'],'preço comercial');abort_if($price===null,422,'Preço desconhecido.');
$out[]=array_replace($origin,['service_id'=>$service->id,'service_version'=>$service->version,'unit_price'=>$price,'price_source'=>$l['price_source'],'description'=>$l['description']]);
}return $out;
@@ -76,6 +76,6 @@ final class WorkshopCorrections {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'generations'=>[]];self::guard();return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$generations=[];
foreach(DB::table('wb_generations as g')->join('wo_orders as o','o.id','=','g.order_id')->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->select('g.*')->get() as $g){$current=null;$issue=null;try{$current=$this->baseline($g,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$issue=$e->getMessage();}$requests=DB::table('wc_requests')->where('generation_id',$g->id)->orderBy('version')->get()->all();foreach($requests as $r){$r->snapshot=json_decode($r->snapshot,true);$r->review=DB::table('wc_reviews')->where('request_id',$r->id)->first();$r->applied=DB::table('wc_applications')->where('request_id',$r->id)->first();$r->current=!DB::table('wc_requests')->where('previous_id',$r->id)->exists()&&$current!==null&&$this->canonical($r->snapshot['baseline'])===$this->canonical($current);}
$generations[]=(array)$g+['current'=>$current,'issue'=>$issue,'requests'=>$requests,'applications'=>DB::table('wc_applications')->where('generation_id',$g->id)->orderBy('sequence')->get()->all()];}
- return ['blocked'=>false,'generations'=>$generations,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
+ return ['blocked'=>false,'generations'=>$generations,'services'=>\App\Shared\Context\ModuleRecords::query('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
}
}
diff --git a/app/Modules/Workshop/WorkshopWorkflow.php b/app/Modules/Workshop/WorkshopWorkflow.php
index f74860a..83ade10 100644
--- a/app/Modules/Workshop/WorkshopWorkflow.php
+++ b/app/Modules/Workshop/WorkshopWorkflow.php
@@ -79,7 +79,7 @@ final class WorkshopWorkflow
self::guard();
$d=$this->exact($input,['reference'=>'required|string|min:3|max:100','client_id'=>'required|integer|min:1','equipment_id'=>'nullable|integer|min:1','equipment_identifier'=>'required_without:equipment_id|string|max:100','equipment_description'=>'required_without:equipment_id|string|max:300','serial'=>'nullable|string|max:100','request'=>'required|string|min:3|max:2000','location'=>'required|string|max:300','evidence'=>'required|string|min:3|max:500']);
return $this->command($actor,'open',$key,$d,null,function($s)use($d,$actor) {
- abort_unless(DB::table('a_clients')->where(['id'=>$d['client_id'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente de outra empresa.');
+ abort_unless(\App\Shared\Context\ModuleRecords::query('a_clients')->where(['id'=>$d['client_id'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente de outra empresa.');
abort_if(DB::table('wo_orders')->where(['company_id'=>$s['company_id'],'reference'=>$d['reference']])->exists(),409,'Referência de ordem já existente.');
$equipment=$d['equipment_id']??null;
if($equipment) abort_unless(DB::table('wo_equipment')->where(['id'=>$equipment,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id']])->exists(),403,'Equipamento incompatível com o cliente.');
@@ -231,7 +231,7 @@ final class WorkshopWorkflow
$orders[]=(array)$o+['equipment'=>DB::table('wo_equipment')->find($o->equipment_id),'revision'=>$this->revision($o->id),'assigned_to'=>$entries->where('kind','assignment')->last()?->technician_id,'state'=>$state,'entries'=>$entries->all(),'minutes_reported'=>$current->where('kind','time')->sum('minutes'),'minutes_verified'=>$current->where('kind','time')->filter(fn($e)=>$e->decision?->decision==='verified')->sum('minutes'),'materials'=>array_values($materials)];
}
$techs=$this->has($actor,'assign',$s)?DB::table('permission_grants as g')->join('users as u','u.id','=','g.user_id')->where(['g.company_id'=>$s['company_id'],'g.unit_id'=>$s['unit_id'],'g.permission'=>'workshop.record'])->get(['u.id','u.name'])->all():[];
- $clients=DB::table('a_clients')->where(['company_id'=>$s['company_id'],'tenant_id'=>$s['tenant_id']]);
+ $clients=\App\Shared\Context\ModuleRecords::query('a_clients')->where(['company_id'=>$s['company_id'],'tenant_id'=>$s['tenant_id']]);
if(!$this->has($actor,'open',$s)) $clients->whereIn('id',array_column($orders,'client_id'));
return ['blocked'=>false,'notice'=>self::NOTICE,'policy'=>self::POLICY,'orders'=>$orders,'clients'=>$clients->get(['id','name'])->all(),'technicians'=>$techs];
});
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 2c3b539..6738b1b 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -17,6 +17,9 @@ class AppServiceProvider extends ServiceProvider
{
require_once base_path('packages/budget-control/bootstrap.php');
require_once base_path('packages/payables/bootstrap.php');
+ if (is_file(base_path('packages/purchasing/current.json'))) {
+ require_once base_path('packages/purchasing/bootstrap.php');
+ }
$this->app->scoped(CompanyContext::class, fn () => new CompanyContext());
}
diff --git a/app/Shared/Context/ModuleNavigation.php b/app/Shared/Context/ModuleNavigation.php
new file mode 100644
index 0000000..c58132d
--- /dev/null
+++ b/app/Shared/Context/ModuleNavigation.php
@@ -0,0 +1,60 @@
+'Comercial', 'purchasing'=>'Compras', 'stock'=>'Stock', 'workshop'=>'Oficinas', 'finance'=>'Financeiro', 'accounting'=>'Contabilidade'];
+ private const RIGHTS = [
+ 'commercial'=>['clients.', 'services.', 'service_drafts.', 'billing_drafts.', 'issuer_documents.', 'client_documents.', 'draft_documents.', 'service_invoices.'],
+ 'purchasing'=>['purchasing_budget.', 'supplier_payables.'],
+ 'stock'=>['stock.'], 'workshop'=>['workshop.', 'workshop_billing.', 'workshop_corrections.'],
+ 'finance'=>['direct_costs.', 'industrial_costs.', 'industrial_budgets.', 'industrial_budget_comparisons.', 'service_receivables.'],
+ 'accounting'=>['catalogue_accounting.', 'service_fiscal.'],
+ ];
+
+ public function available(CompanyContext $context): array
+ {
+ $s = $context->identifiers();
+ $grants = DB::table('permission_grants')->where($s)->where('user_id', $context->userId())
+ ->where(fn($q) => $q->whereNull('unit_id')->orWhere('unit_id', $context->unitId()))->pluck('permission')->all();
+ $development = app()->environment(['local','testing','browser-testing'])
+ && in_array($context->userId().':'.$s['tenant_id'].':'.$s['company_id'], config('modules.development_profiles', []), true);
+ if ($development) {
+ // A local environment name alone must never turn client accounts into
+ // development profiles. Discovery also requires the isolated DB guard.
+ try { \App\Shared\Testing\TestingDatabaseGuard::assertSafe(); }
+ catch (\RuntimeException) { $development=false; }
+ }
+ $policy = config('modules.companies.'.$s['tenant_id'].':'.$s['company_id'], []);
+ $contracted = $policy['contracted'] ?? config('modules.contracted', []);
+ $enabled = $policy['enabled'] ?? $contracted;
+ $out = [];
+ foreach (self::LABELS as $id => $label) {
+ $authorized = false;
+ foreach ($grants as $permission) {
+ foreach (self::RIGHTS[$id] as $prefix) {
+ if (str_starts_with($permission, $prefix) && str_ends_with($permission, '.view')) { $authorized = true; }
+ }
+ }
+ if ($development || ($authorized && in_array($id,$contracted,true) && in_array($id,$enabled,true))) {
+ $out[] = ['id'=>$id, 'label'=>$label, 'development'=>$development, 'operations_authorized'=>$authorized];
+ }
+ }
+ return $out;
+ }
+
+ // Ownership of existing endpoints, not an attribution of legacy rows to a new module.
+ // Company, unit and access configuration are explicitly general.
+ public static function owner(string $path): ?string
+ {
+ if (preg_match('~^(supplier-payables|supplier-payments|api/(supplier-payables|supplier-payments|purchasing-budget))(/|$)~', $path)) return 'purchasing';
+ if (preg_match('~^api/(workshop|workshop-billing|workshop-corrections)(/|$)~', $path)) return 'workshop';
+ if (preg_match('~^api/(direct-costs|industrial-costs|industrial-budgets|service-receivables)(/|$)~', $path)) return 'finance';
+ if (preg_match('~^api/(catalogue-accounting|service-fiscal)(/|$)~', $path)) return 'accounting';
+ if (preg_match('~^api/(a|b1|service-invoices|service-calculations|document-preview|document-preparation)(/|$)~', $path)) return 'commercial';
+ return null;
+ }
+}
diff --git a/app/Shared/Context/ModuleRecords.php b/app/Shared/Context/ModuleRecords.php
new file mode 100644
index 0000000..50cce7b
--- /dev/null
+++ b/app/Shared/Context/ModuleRecords.php
@@ -0,0 +1,49 @@
+attributes->get('sgf_module'); }
+ public static function query(string $table)
+ {
+ $query=DB::table($table);
+ $module=self::active();
+ if (!$module) return $query; // CLI domain proofs have no HTTP module selection.
+ if (str_starts_with($table,'a_') && in_array(substr($table,2),self::AREAS,true)) {
+ self::constrain($query,$table,substr($table,2),$module);
+ } elseif ($table==='si_invoices') {
+ $query->whereIn('draft_id',self::query('a_billing_drafts')->select('id'));
+ }
+ return $query;
+ }
+ public static function constrain($query,string $table,string $area,string $module): void
+ {
+ $query->whereExists(function($q)use($table,$area,$module){
+ $q->selectRaw('1')->from('identity_audit as ma')->where('ma.action','module.record.associated')
+ ->whereColumn('ma.tenant_id',$table.'.tenant_id')->whereColumn('ma.company_id',$table.'.company_id')
+ ->whereRaw("ma.details::jsonb->>'area' = ?",[$area])
+ ->whereRaw("ma.details::jsonb->>'record_id' = {$table}.id::text")
+ ->whereRaw("ma.details::jsonb->>'module_id' = ?",[$module])
+ ->whereNotExists(function($revoked){
+ $revoked->selectRaw('1')->from('identity_audit as mr')->where('mr.action','module.record.disassociated')
+ ->whereColumn('mr.tenant_id','ma.tenant_id')->whereColumn('mr.company_id','ma.company_id')->whereColumn('mr.id','>','ma.id')
+ ->whereRaw("mr.details::jsonb->>'area' = ma.details::jsonb->>'area'")
+ ->whereRaw("mr.details::jsonb->>'record_id' = ma.details::jsonb->>'record_id'")
+ ->whereRaw("mr.details::jsonb->>'module_id' = ma.details::jsonb->>'module_id'");
+ });
+ });
+ }
+ public static function associate(string $area,int $id,int $actor,array $scope,string $module,string $reason,bool $remove=false,?int $version=null): void
+ {
+ Audit::record($remove?'module.record.disassociated':'module.record.associated',$actor,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']??null,
+ details:['area'=>$area,'record_id'=>$id,'module_id'=>$module,'reason'=>$reason,'version'=>$version]);
+ }
+}
diff --git a/app/Shared/Context/RequireModuleContext.php b/app/Shared/Context/RequireModuleContext.php
new file mode 100644
index 0000000..23a02b6
--- /dev/null
+++ b/app/Shared/Context/RequireModuleContext.php
@@ -0,0 +1,27 @@
+path());
+ if ($owner === null) return;
+ $selected = $request->session()->get('company_context', []);
+ $active = $selected['module_id'] ?? null;
+ abort_unless($active, 409, 'Seleccione explicitamente um módulo antes de abrir este percurso.');
+ $available = array_column(app(ModuleNavigation::class)->available($context), 'id');
+ abort_unless(in_array($active, $available, true), 403, 'O acesso ao módulo foi revogado ou a configuração mudou.');
+ $shared = $request->is('api/a/*','api/b1/*','api/document-preview/draft/*','api/document-preview/history/*','api/document-preparation/*');
+ abort_unless($active === $owner || $shared, 409, 'Este percurso pertence a outro módulo. Mude de módulo antes de o abrir.');
+ $request->attributes->set('sgf_module',$active);
+ // Reads also carry the generation: stale tabs and late requests cannot mix contexts.
+ // Standalone HTML pages use the current session; their APIs must carry the token.
+ if ($request->is('api/*') && !$request->is('api/service-invoices/*/pdf')) {
+ abort_unless(hash_equals($selected['token'] ?? '', (string)$request->header('X-SGF-Context')), 409, 'O contexto mudou. Reabra este percurso.');
+ }
+ }
+}
diff --git a/config/modules.php b/config/modules.php
new file mode 100644
index 0000000..7f0ddc6
--- /dev/null
+++ b/config/modules.php
@@ -0,0 +1,13 @@
+ ['commercial', 'purchasing', 'stock', 'workshop', 'finance', 'accounting'],
+ 'companies' => [], // "tenant:company" => ['contracted' => [...], 'enabled' => [...]]
+ // Explicit development discovery profiles, scoped as "user:tenant:company".
+ // Empty in the installed product; never populated from client names or emails.
+ // This is not an operational permission and is ignored in production.
+ 'development_profiles' => array_values(array_filter(array_map('trim', explode(',', (string) env('SGF_DEVELOPMENT_PROFILES', ''))),
+ fn($profile) => preg_match('/^[1-9][0-9]*:[1-9][0-9]*:[1-9][0-9]*$/D', $profile))),
+];
diff --git a/docs/MODULAR_NAVIGATION_COMPLETION.md b/docs/MODULAR_NAVIGATION_COMPLETION.md
new file mode 100644
index 0000000..614dc9d
--- /dev/null
+++ b/docs/MODULAR_NAVIGATION_COMPLETION.md
@@ -0,0 +1,64 @@
+# Navegação modular — regressão e perfis de desenvolvimento
+
+Actualização de 01/11/2026 à [entrega inicial](MODULAR_NAVIGATION_DELIVERY.md). Concluídas as duas frentes técnicas: regressão da suite existente e configuração de perfis sintéticos num ambiente de desenvolvimento separado. A [revisão visual](MODULAR_NAVIGATION_VISUAL_REVIEW.md) continua pendente e separada.
+
+## Isolamento e preservação
+
+Foi criado um cluster PostgreSQL próprio em loopback **55439**, identificado como `sgf_modular_regression_2576427d1939`. A aplicação principal, a demonstração anterior e a sua guarda de base permanecem no destino original; não foram paradas nem migradas para este cluster.
+
+| Finalidade | Aplicação/base/role |
+| --- | --- |
+| Regressão descartável | Cópia da aplicação em `C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/app`; base `sgf_tempo_test`; role `sgf_test_runner`. |
+| Desenvolvimento persistente isolado | Cópia em `development-app`, no mesmo directório de execução; base `sgf_modules_dev_2576427d1939`; role `sgf_modules_qa_2576427d1939`; HTTP `http://127.0.0.1:8039`. |
+
+As roles das aplicações não têm administração do cluster, criação de bases/roles ou bypass de RLS. A base de desenvolvimento tem proprietário e acesso distintos da regressão: recriar a base de regressão não apaga os perfis ou a demonstração de desenvolvimento.
+
+As cópias recebem apenas adaptações explícitas da identidade física: porta da regressão e, na cópia de desenvolvimento, nome de base/role. As guardas PHP e SQL continuam a comparar identidades exactas; não foram removidas. Os ficheiros adaptados são enumerados nos manifestos locais `port-adaptations.json` e `development-identity-adaptations.json`. A guarda do repositório principal continua intacta.
+
+As migrações existentes foram aplicadas apenas às bases novas deste cluster. Não há migração nova de produto nem aplicação de migração em instalação pública, dados reais ou demonstração anterior. Não foram activadas candidatas de Compras: a cópia preserva o apontador instalado, sem mudar a versão seleccionada no host original.
+
+A conferência de preservação comparou contagem e impressão digital de `permission_grants`, `company_memberships` e `identity_audit` da instalação SGF, a guarda original e o apontador instalado de Compras. Os cinco resultados permaneceram iguais. Os registos e credenciais não foram exportados para o relatório.
+
+## Regressão
+
+- Suite histórica completa do checkout: **185 testes, 3 220 asserções**, aprovada.
+- Inclui identidade, isolamento de empresas, catálogo, prestações/rascunhos, B1, preparação/pré-visualização, classificação contabilística, cálculo/perfis fiscais, facturas/PDF, recebimentos, oficinas, custos directos/industriais, orçamento e pacote de Compras instalado.
+- Os clientes de teste passaram a declarar o módulo da sua fixture e a enviar o token nas leituras. As asserções de negócio anteriores foram conservadas, incluindo recusas, versões, idempotência, rollback e bloqueios SQL.
+- `UsesModuleFixture` prepara associações explícitas apenas para os dados sintéticos novos da suite, no cluster descartável. Não é middleware nem mecanismo de migração de registos antigos. Não executa atribuições na instalação ou na demonstração anterior.
+- A preparação destrutiva dos testes exige agora um identificador de execução e confere o nome efectivo do cluster antes de `RefreshDatabase`. A execução directa da suite destrutiva na demonstração persistente é recusada.
+- A suite focada anterior não foi repetida por rotina. A alteração da configuração dos perfis e a nova guarda de descoberta afectaram esses testes; por isso foi feita a repetição delimitada, com mais um caso de protecção: **8 testes, 90 asserções**, aprovados.
+- Não houve alterações de interface nesta continuação, pelo que não se repetiu o build nem os testes estáticos de navegação já aprovados. As verificações de sintaxe e de whitespace das alterações novas passaram.
+
+As primeiras tentativas da regressão identificaram que os destinos físicos estavam também fixados em ficheiros SQL. O runner passou a adaptar esses ficheiros na cópia isolada, mantendo as condições de recusa. Os resultados de aprovação correspondem à execução com a identidade isolada coerente, sem desligar guardas.
+
+## Perfis configurados e conferidos por HTTP
+
+São três contas novas, sintéticas, da empresa/unidade criadas na base própria. Nenhuma conta de cliente foi seleccionada ou recebeu perfil.
+
+| Perfil | Descoberta | Autoridade exemplificada |
+| --- | --- | --- |
+| Preparador | Seis módulos | Preparação de custos e rascunhos; sem validação dos seus custos. Administração explícita apenas nesta empresa sintética. |
+| Revisor | Seis módulos | Validação de custos e revisão comercial; sem preparação correspondente ou administração de acessos. |
+| Consulta | Seis módulos | Consulta concedida; sem preparação, validação ou administração. |
+
+`SGF_DEVELOPMENT_PROFILES` permite configurar os identificadores exactos `user:tenant:company` no ficheiro de ambiente privado da instância. A configuração predefinida do produto continua vazia. A descoberta exige ambiente de desenvolvimento **e a guarda da base isolada**; copiar um perfil para configuração de base de cliente não o activa. Em produção o desvio de descoberta continua indisponível.
+
+As verificações HTTP autenticaram separadamente as três contas, seleccionaram os seis módulos e conferiram a separação de autoridade. Confirmaram também recusas de escrita para revisão/consulta, recusa da administração a esses perfis e manutenção das guardas dos adaptadores de fornecedores. Não houve navegador, capturas, pagamento ou associação automática.
+
+Stock continua em preparação. A descoberta de Compras não instala configuração operacional, fontes históricas ou capacidades candidatas: os adaptadores que exigem a sua composição/base QA própria continuam a recusar execução nesta base. As funções disponíveis conservam as condições e limitações anteriores.
+
+O exemplo de cadastro pendente continua sem qualquer associação de módulo, visível apenas na consulta administrativa autorizada e sinalizado como pendente. As listagens operacionais não o incorporam. Nenhum registo anterior foi atribuído por defeito a Comercial, Compras ou outro módulo.
+
+As credenciais novas estão exclusivamente em `C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json`, fora do Git e da raiz web. Não usar nem substituir o ficheiro de entrada manual da oficina ou as credenciais anteriores.
+
+## Operação e recuperação locais
+
+Os scripts de preparação são `modular-regression-run.mjs` e `modular-development-host.mjs`. O primeiro copia o checkout e prepara/testa o cluster descartável; o segundo prepara uma aplicação, base e role diferentes para os perfis persistentes. Os testes focados podem ser seleccionados separadamente pelo modo `focused`. As verificações HTTP estão em `modular-development-http-proof.mjs`.
+
+Não repetir o provisionamento sobre uma demonstração existente: o script recusa um host de desenvolvimento já configurado. Para suspender a instância, usar o PID local de `development-web.pid` e o `pg_ctl` com o directório exacto do cluster acima, depois de conferir a identidade. Não usar serviços, bases ou credenciais da instalação principal para recuperar este ambiente. Não apagar associações, dados antigos ou apontadores de Compras como procedimento de recuperação.
+
+O directório local conserva os relatórios JUnit, saídas completas, manifestos das adaptações e provas HTTP. A [prova resumida](proofs/modular-navigation-completion.json) contém apenas resultados técnicos, sem credenciais.
+
+Continuam separados: revisão visual/aceitação da equipa, comportamento visual de recarga das páginas autónomas, atribuição humana das associações antigas, avaliação de volume/paginação e qualquer implantação/uso real. A suite automatizada completa não declara executadas verificações manuais ou provas externas que não pertençam a ela.
+
+Sem publicação, push, checkpoint de aceitação, alterações dos direitos públicos ou activação de candidatas de Compras.
diff --git a/docs/MODULAR_NAVIGATION_DELIVERY.md b/docs/MODULAR_NAVIGATION_DELIVERY.md
new file mode 100644
index 0000000..db1f11b
--- /dev/null
+++ b/docs/MODULAR_NAVIGATION_DELIVERY.md
@@ -0,0 +1,70 @@
+# Navegação modular SGF — implementação local, 01/11/2026
+
+Actualização posterior: a regressão completa e os perfis de desenvolvimento isolados foram concluídos em [MODULAR_NAVIGATION_COMPLETION.md](MODULAR_NAVIGATION_COMPLETION.md). A [revisão visual](MODULAR_NAVIGATION_VISUAL_REVIEW.md) permanece separada. As pendências dessas duas frentes descritas abaixo correspondem ao estado da entrega inicial.
+
+Implementada a selecção explícita do módulo na sessão existente, navegação própria e opções gerais separadas. A página inicial mantém o conteúdo limpo anterior. Não há aceitação visual presumida, publicação, checkpoint Git ou activação de candidatas de Compras.
+
+## Navegação e autorização
+
+- Módulos: Comercial, Compras, Stock, Oficinas, Financeiro e Contabilidade, a partir do inventário de navegação existente. Custos/orçamentos e recebimentos conservam os percursos anteriores no Financeiro; não foi criado outro motor.
+- O selector permanece visível, incluindo nas opções gerais. A navegação secundária apresenta apenas as funcionalidades permitidas do módulo activo e os cadastros/rascunhos partilhados permitidos.
+- Empresa, unidades, acessos e administração das associações são opções gerais. A administração das associações exige `access.manage` e os direitos originais de consulta; cada decisão exige também edição do registo.
+- A disponibilidade de módulos é recalculada no servidor com os direitos actuais, empresa e unidade seleccionadas. Revogar direitos ou desactivar um módulo impede pedidos seguintes. Seleccionar um contexto não insere concessões.
+- `config/modules.php` contém a composição instalada e restrições opcionais por `tenant:company` (`contracted` e `enabled`). Não foi encontrado um cadastro anterior de subscrições/contratação: esta política não declara implementado um serviço de licenciamento. Permanecem as guardas dos serviços e dos pacotes instalados.
+- Os perfis de descoberta de desenvolvimento são explicitamente delimitados por `user:tenant:company` em `development_profiles`, apenas em `local`, `testing` e `browser-testing`. Podem apresentar os seis módulos; operações continuam a exigir os direitos próprios. A lista entregue está vazia: nenhuma pessoa/conta de cliente recebeu um perfil de desenvolvimento. Não foi inventado um papel de desenvolvimento a partir de nomes, emails ou administração do cliente.
+- A continuação acrescentou configuração privada por ambiente e verificação da guarda de base isolada também para a descoberta; o produto continua sem perfis de cliente atribuídos por defeito.
+- Um acesso directo a uma função de outro módulo exige mudança explícita de contexto. Ligações internas entre percursos fazem essa mudança pelo servidor, sujeita à protecção de alterações pendentes e nova autorização.
+
+## Associação dos dados e isolamento
+
+O levantamento confirmou `tenant_id`, `company_id` e, quando aplicável, `unit_id`. Não existe uma coluna genérica anterior de módulo em clientes, serviços ou rascunhos. Não se inferiu a associação a partir do autor, do nome, de uma data ou da ausência de outra origem.
+
+| Registos | Associação utilizada |
+| --- | --- |
+| `a_clients`, `a_services`, `a_service_drafts`, `a_billing_drafts` | Eventos explícitos de associação no registo de auditoria imutável existente. |
+| Novas criações desses quatro tipos por HTTP | Evento na mesma transacção da criação, com módulo activo, autor, empresa, unidade e versão. |
+| Registos anteriores desses quatro tipos | Sem atribuição automática. Consulta administrativa geral, com os direitos originais, apresenta os registos e a pendência. |
+| Facturas de teste e respectivo PDF | Associação do rascunho identificado por `si_invoices.draft_id`, além dos âmbitos e direitos originais. |
+| Oficina e correcções, custos, obrigações e liquidações | Percurso nativo e relações existentes de ordem, geração, lote, documento ou obrigação; mantidas as fotografias e identidades de origem. |
+| Empresa, unidade, configuração do emitente e instruções gerais | Âmbito geral de empresa/unidade existente; não foram copiadas para módulos. |
+| Relatórios ainda em preparação | Continuam sem geração de dados ou exportações; não foram inventadas fontes. |
+
+`ModuleRecords` aplica `WHERE EXISTS` às consultas dos quatro cadastros/rascunhos e à leitura das facturas. A filtragem não é apenas visual: leitura por ID, origens seleccionáveis, preparação documental, pré-visualização, histórico e consultas dos serviços usam a associação verificada. A API partilhada mantém as exigências originais de cada área/acção/unidade.
+
+Os avisos da interface e `scope_notice` explicam que dados anteriores sem associação estão preservados e pendentes. A vista administrativa geral não é uma listagem operacional do módulo e não está disponível a leitores sem `access.manage`. O responsável precisa de consulta e edição próprias para decidir uma associação com motivo e versão exacta; uma versão entretanto alterada é recusada com 409.
+
+Associar o rascunho não associa automaticamente o cliente, o catálogo ou a prestação de origem. Cada vínculo necessário tem de ser conferido explicitamente. Uma associação pode ser retirada por novo evento e posteriormente reposta; os eventos anteriores permanecem. O registo de negócio, a sua versão, fotografia, história e responsabilidade financeira não são reescritos nem duplicados.
+
+Os bloqueios de duplicação, emissão já existente e dependências comerciais continuam a consultar as relações canónicas, mesmo quando uma dependência não é visível no módulo corrente. A filtragem de apresentação não liberta origens nem autoriza um segundo efeito.
+
+## Mudança de contexto
+
+- Empresa, unidade e módulo geram um token novo; os formulários e selecções anteriores são desmontados. Os pedidos API de leitura e escrita levam `X-SGF-Context`; pedidos de outra geração recebem 409. Respostas atrasadas são ignoradas na interface.
+- Guardar, descartar ou cancelar é oferecido antes de sair de um editor alterado. Guardar usa a gravação já existente; falha, validação recusada ou novas alterações durante a gravação mantêm o contexto.
+- Operações em curso impedem a mudança. Processos já submetidos conservam IDs, versões, origens e fotografias anteriores.
+- Editores sem formulário submetível mantêm o contexto e pedem gravação explícita no próprio percurso. A detecção é conservadora e pode também sinalizar filtros/seleccionadores em secções sem formulário.
+- As duas páginas autónomas de fornecedores recebem identificação do módulo e protecção de saída pelo invólucro SGF, sem editar o pacote partilhado. Com mais de um formulário autónomo alterado, pede-se gravação individual, porque os handlers originais recarregam a página após sucesso.
+- Actualizar/fechar a página usa também a protecção nativa de saída do navegador. A navegação interna usa o diálogo de três opções, com teclado e foco delimitado.
+
+## Provas efectuadas
+
+- `php vendor/phpunit/phpunit/phpunit --filter ModuleNavigationTest`: **7 testes, 87 asserções**, aprovados.
+- Casos: contexto explícito, acesso directo, token antigo, revogação, configuração da empresa, descoberta de desenvolvimento sem concessão operacional e sem efeito em produção, selecção recusada mantendo o contexto, auditoria sem token, isolamento entre empresas, associações/retirada/reposição, dados antigos não atribuídos automaticamente, versão desactualizada, gravação de prestação/rascunho e preservação do original/histórico/dependências.
+- Base exclusiva de testes `sgf_tempo_test`, role `sgf_test_runner`, loopback 55432, com guarda existente. As novas provas usam transacções revertidas e identidades/empresas sintéticas com emails únicos; não usam `RefreshDatabase`, `migrate:fresh`, seeds ou reposição da demonstração. Sequências podem avançar com inserções revertidas; não são reiniciadas.
+- `node scripts/module-navigation-check.mjs`: aprovado, incluindo separação dos módulos, módulo visível, gravação, falha, validação, descarte, editor sem submissão, remoção de formulários antigos e edição durante gravação.
+- `npm.cmd run build`: inclui os testes de navegação anterior, os novos testes de módulos e a compilação Vite.
+- Sintaxe PHP dos ficheiros alterados, sintaxe do invólucro JavaScript e `git diff --check`: aprovados.
+
+A suite histórica completa não foi executada: vários testes antigos recriam a base e os clientes HTTP anteriores ainda não seleccionam módulo nem enviam token nas leituras. Não se declara essa regressão completa realizada. As provas acima cobrem o novo contrato e uma passagem real de cadastro → prestação → rascunho em transacção isolada.
+
+## Migração, recuperação e limites
+
+Não há migração SQL nova. As associações usam os eventos `module.record.associated` e `module.record.disassociated` em `identity_audit`, que já é append-only. Não foi alterada a estrutura, uma concessão, configuração nominativa, registo empresarial ou demo persistente por instalação deste incremento. O cluster PostgreSQL local existente foi iniciado para executar as provas.
+
+Para corrigir uma associação, usar **Opções gerais → Associações de módulos**, seleccionar a operação de retirada ou associação, a versão apresentada e o fundamento. A recuperação acrescenta um evento e conserva o anterior; não apagar auditoria, origens ou documentos. Desactivar um módulo na política impede acesso futuro sem apagar dados. Não alterar a versão candidata/instalada de Compras para recuperar a navegação.
+
+Para retirar esta implementação local, reverter apenas os ficheiros/hunks deste incremento e recompilar; não usar `git reset`/restauro global, pois já existiam alterações de Compras, pagamentos, reversões, rotas e provider antes deste trabalho. Conservar os eventos e dados existentes. Retirar as guardas modulares também retira esta camada de isolamento; não é um procedimento de publicação ou uma autorização de uso real.
+
+Limites pendentes: revisão visual e interacção real no navegador, comportamento de recarga das páginas autónomas, regressão integral dos percursos anteriores, associação humana dos dados antigos e configuração explícita dos perfis de desenvolvimento. A consulta administrativa de associações ainda não tem paginação; a filtragem sobre auditoria deve ser avaliada com volumes de implantação antes de uso real. Não foi feito novo controlo de navegador nem atribuída aceitação à equipa.
+
+Não houve publicação/push, alteração dos direitos da instalação pública, activação de Compras candidata, novas operações financeiras, remoção de guardas reais ou alteração de dependências.
diff --git a/docs/MODULAR_NAVIGATION_VISUAL_REVIEW.md b/docs/MODULAR_NAVIGATION_VISUAL_REVIEW.md
new file mode 100644
index 0000000..2a6dc12
--- /dev/null
+++ b/docs/MODULAR_NAVIGATION_VISUAL_REVIEW.md
@@ -0,0 +1,18 @@
+# Revisão visual da navegação modular — pendente
+
+Registo separado, 01/11/2026. A revisão visual não foi executada nem aceite. Não houve nova tentativa de controlo de navegador pela via bloqueada.
+
+A compilação, renderização estática, testes PHP e verificações HTTP são provas técnicas; não substituem a observação da interface e a interacção da equipa.
+
+Instância isolada para revisão futura: `http://127.0.0.1:8039`, com perfis sintéticos de preparação, revisão e consulta. Não usar as credenciais ou os dados da instalação pública. O acesso local está no ficheiro privado gerado pelo procedimento de configuração, fora do repositório e da raiz pública.
+
+Pontos a observar quando houver uma via de revisão disponível:
+
+- Menu principal, identificação persistente do módulo e separação das opções gerais.
+- Navegação própria de cada módulo, estados sem funcionalidades permitidas e funções em preparação.
+- Página inicial limpa e apresentação em larguras pequenas.
+- Diálogo guardar/descartar/cancelar, foco, teclado, falha de gravação e operações em curso.
+- Saída das páginas autónomas de fornecedores, incluindo recarga após gravação e vários formulários alterados.
+- Sinalização dos dados anteriores sem associação e consulta administrativa sujeita a direitos próprios.
+
+Sem capturas novas, confirmação visual atribuída à equipa ou autorização de publicação decorrente das provas técnicas.
diff --git a/docs/PURCHASING_COMMON_CORE_PLAN.md b/docs/PURCHASING_COMMON_CORE_PLAN.md
index 1739d9b..1c74336 100644
--- a/docs/PURCHASING_COMMON_CORE_PLAN.md
+++ b/docs/PURCHASING_COMMON_CORE_PLAN.md
@@ -1,5 +1,11 @@
# Compras e núcleo financeiro comum — desenho e transição
+**Candidata interna, sem activação — 01/11/2026:** motor comum e adaptadores preparados para requisição → submissão/reserva → aprovação independente → consulta, com versões, histórico, autorização renovada e isolamento por empresa/unidade. Testes sintéticos isolados e recuperação de dados/schema separados do rollback de código. Ver [matriz e entrega](PURCHASING_UPDATES_DELIVERY.md). Código candidato e artefactos permanecem em Compras; rotas/schema/concessões não foram instalados neste SGF. A versão activa mantém-se **0.1.1**, por instrução expressa; nenhuma autorização implícita para activar 0.1.2 ou nova versão. A preparação operacional de instalação e catálogo administrativo permanece pendente. Contratações para terceiros continuam excluídas.
+
+**Conferência antes de ampliar, 01/11/2026:** matriz de integração revista por processo, adaptador e ecrã, com estados transferido/parcialmente transferido/pendente e exclusão explícita de contratações para terceiros no SGF. Instalação cliente independente da origem demonstrada; rollback cobre apenas código, não dados/schema. SGF mantém `0.1.1`; candidata económica `0.1.2` ficou preparada mas não instalada após interrupção da ampliação em curso. Consultar a secção inicial de [PURCHASING_UPDATES_DELIVERY.md](PURCHASING_UPDATES_DELIVERY.md) antes de escolher o próximo percurso completo. Não inferir activação ou transferência a partir da existência de candidata no canal.
+
+**Prioridade e execução de 01/11/2026:** proprietário pediu foco na transferência de Compras para o SGF e mecanismo de updates. Primeiro pacote originado em Compras realmente instalado/executado no SGF: projecção de reposição, sem outro motor nem dependência da pasta fonte em runtime. Publicação/instalação por versões imutáveis, hashes, prova candidata no PHP do SGF, ponteiro atómico, lock, histórico e rollback; `0.1.0/0.1.1` e reversão/reactivação conferidos. Duas provas Unit/11 asserções, sem base, preservam a demonstração integrada e dados/acessos. O contrato ainda recusa alterações de schema/permissões; restante transferência funcional, migrações de módulos e UI permanecem pendentes. Ver [transferência e comandos](PURCHASING_UPDATES_DELIVERY.md). Os estados históricos abaixo conservam os seus âmbitos; este incremento não declara o módulo completo instalado.
+
**T2 sintético implementado, 29/10/2026:** [obrigação de fornecedor sobre factura conferida](SUPPLIER_PAYABLE_DELIVERY.md), fonte única, duas composições, sem pagamentos. Mantém orçamento entregue, demos e pendências da equipa; não equivale a migração ou implantação.
**Orientação comum posterior:** as [oito regras de produto SGF/Compras](SUPPLIER_PAYABLE_SCOPE.md#14-orientação-comum-de-produto-da-tempo--29102026) distinguem documento, dívida, liquidação e contabilidade e orientam os contratos futuros. T2 continua limitado à obrigação a fornecedor; nenhuma ampliação automática a pagamentos/cobranças. Vencimento desconhecido mantém pendência explícita, sem apagar principal nem inventar data.
diff --git a/docs/PURCHASING_UPDATES_DELIVERY.md b/docs/PURCHASING_UPDATES_DELIVERY.md
new file mode 100644
index 0000000..898440e
--- /dev/null
+++ b/docs/PURCHASING_UPDATES_DELIVERY.md
@@ -0,0 +1,232 @@
+# Transferência de Compras e actualizações do SGF
+
+## Objectivo e estado em 01/11/2026
+
+O foco autorizado pelo proprietário é transferir as capacidades de Compras para o SGF e levar as melhorias seguintes por versões verificáveis. Não é sincronizar bases de desenvolvimento nem manter duas implementações. O SGC autónomo conserva todas as suas funcionalidades; no SGF, Pessoas e contratações destina-se apenas ao uso interno. Cada cliente recebe as capacidades adquiridas, com configuração e permissões próprias.
+
+**Implementado neste incremento:** mecanismo de publicação/instalação/actualização/reversão e primeira capacidade realmente extraída e executada no SGF: projecção de reposição de stock. **A transferência integral dos processos, ecrãs, identidade e dados de negócio ainda não está concluída.**
+
+**Conferência antes de ampliar, 01/11/2026:** a prioridade passa a ser esta matriz e a delimitação de instalação/reversão, conforme pedido posterior do proprietário. SGF local activo em `0.1.1`. O núcleo económico começou a ser preparado antes desse pedido; a transferência em curso foi interrompida antes de nova activação. Existe candidata `0.1.2` no canal, sem instalação no SGF. Código preparado na origem não conta como capacidade transferida.
+
+**Actualização posterior autorizada:** percurso de requisição interna implementado como candidata de revisão `IR-CANDIDATE-bfa807e2b3c4fae7`, testada sem activar o destino. A matriz abaixo distingue essa implementação candidata da capacidade instalada. Ver detalhes, recuperação e limites na secção do percurso escolhido.
+
+## Fonte única e fronteira dos dois hosts
+
+`packages/purchasing` em Compras é a fonte canónica. `App\Services\ReplenishmentProjection` passou a fachada de compatibilidade; os consumidores actuais conservam a assinatura e usam o motor comum `Tempo\Purchasing\Supply\ReplenishmentProjection`. O SGF carrega os mesmos bytes de uma pasta de versão instalada. Não há chamada HTTP a Compras, procura da pasta vizinha, segundo algoritmo ou alteração das dependências instaladas.
+
+As diferenças concretas que impedem uma cópia directa da aplicação permanecem: SGF usa tenant/empresa fiscal/unidade e concessões/contexto próprios, enquanto Compras usa tenant/departamento e perfis; ambos têm classes `App` e tabelas-base com significados diferentes. Os frontends e versões de PHP/Brick Math também diferem. Não copiar os controladores, tabelas `users`/`tenants` ou histórias de migração de um host para o outro. Extrair capacidades e adaptar contexto/acesso explicitamente.
+
+## Matriz de integração conferida
+
+Estados: **Transferido** identifica a capacidade exacta instalada e executada no SGF; **Parcialmente transferido** identifica componentes comuns existentes, com lacunas no percurso; **Pendente** identifica capacidades ainda não incorporadas no SGF. A presença de um menu, cálculo ou classe não comprova transferência do processo completo. Capacidades excluídas do SGF são identificadas à parte.
+
+| Processo/capacidade | Estado | Núcleo e persistência | Adaptadores de identidade/acesso e integração | Ecrãs e percurso no SGF | Evidência/lacuna determinante |
+| --- | --- | --- | --- | --- | --- |
+| Publicação, instalação e actualização de código do pacote | **Transferido** | Artefactos imutáveis, hashes, lock, histórico e ponteiro local; sem schema | CLI local e prova com PHP do host; sem alteração de direitos | CLI implementada; painel de updates não implementado | `0.1.0/0.1.1`, retry e rollback de código conferidos; migrações e canal remoto assinado pendentes |
+| Cálculo de projecção de reposição | **Transferido** | Mesmo motor de consumo manual, média racional, reservas/entregas e lotes; sem persistência própria | Carregamento local independente do checkout de Compras; não concede acesso a dados | Sem novo ecrã; capacidade de cálculo instalada | Pacote activo `0.1.1`, dois testes SGF e prova de cliente independente |
+| Orçamento de Compras no núcleo comum | **Parcialmente transferido** | `packages/budget-control` comum anterior; mesmo motor de reserva | Composição operacional candidata com destino/schema/âmbito explícitos; adaptador instalado continua QA | Interface anterior e candidata de requisições; arranque/rotas operacionais ainda não integrados | 20 controlos novos de preparação; restauro em production/base fora do prefixo QA verificado; sem instalação/activação no destino |
+| Preparação de schema da integração | **Parcialmente transferido — candidata não instalada** | Migração transaccional, contrato do orçamento, registo imutável de revisão/hashes/operadores/backup | CLI `plan`/`migrate` local ao host, sem cópia de dados sintéticos ou concessões | Sem ecrã ou activação implícita; CLI de preparação não substitui instalação de código/rotas | Prova nova `sgf-operational-delivery.json`; destino 0.1.1 preservado |
+| Factura conferida → obrigação a fornecedor | **Parcialmente transferido** | Núcleo comum de obrigações anterior, com origem única | Adaptador SGF e API no âmbito sintético; sem passagem automática de tabelas/IDs de Compras | Consulta/decisão do percurso anterior; sem workflow integral de recepção/conferência de facturas de Compras | `SupplierPayables` conserva guarda de ensaio; fornecedor de histórico SGF não concluído conforme entregas anteriores |
+| Pagamentos e afectações a fornecedores | **Parcialmente transferido** | Núcleo comum e entregas anteriores preservados | Adaptadores/autorizações delimitados nas respectivas entregas; não representam migração de contas ou histórico de cliente | Percurso anterior existe no âmbito documentado; integração completa com novas origens de Compras pendente | `SUPPLIER_SETTLEMENT_DELIVERY.md` e limites de histórico/origens; não afirmar financeiro integral |
+| Identidade, empresa/unidade, perfis e módulos adquiridos | **Parcialmente transferido** | Fundações próprias existentes nos dois hosts; não se fundiram tabelas-base | SGF mantém tenant/empresa fiscal/unidade/concessões; correspondência explícita com o contexto Purchasing por concluir | Sessão/contexto SGF existentes; selector SGC não foi incorporado automaticamente | Sem associação por igualdade de nome, email ou ID; parametrização comercial e permissões continuam distintas |
+| Reorganização modular do SGF pedida posteriormente | **Pendente — sem resultado apresentado** | Entrega distinta da integração candidata de requisições | Não avaliada nem concluída por esta conferência | Não inferir reorganização entregue a partir dos ecrãs candidatos | Exige apresentação própria do âmbito, alterações e resultado; não executada por arrasto |
+| Requisição interna: rascunho, submissão, aprovação independente e consulta | **Parcialmente transferido — candidata; não activa** | Motor único `InternalRequisitions`, adaptadores dos dois hosts; recibos, versões e histórico; reserva existente na mesma transacção | Contexto interno, empresa/unidade, autorização renovada, identidade independente e fontes explicitamente associadas; sem novas concessões no destino | Rascunho/edição, lista, submissão, decisão, histórico e parametrização na candidata; sem novo menu/rota activa no SGF | HTTP, concorrência, revogação, falha/recuperação e schema em bases isoladas; equipamentos/peças e instalação comercial pendentes; destino 0.1.1 |
+| Planeamento de compras | **Pendente** | Itens, marcos e associação a requisições permanecem em Compras | Adaptar unidades, centros, intervenientes e vínculos de origem no SGF | Planeamento e acompanhamento de marcos SGF do roteiro de Compras pendentes | Não gerar requisições duplicadas nem inferir reserva/decisão a partir de um plano |
+| Fornecedores, documentos e dados bancários | **Pendente** | Cadastros/versionamento de Compras ainda não extraídos | Definir correspondências autorizadas de fontes e permissões, sem fundir entidades por nome | Cadastro, validações e consulta SGF de todo o percurso pendentes | Preservar fotografias utilizadas e validação bancária independente |
+| Cotações, descontos e comparação económica | **Pendente** | Descontos e pontuação foram preparados na origem/candidata `0.1.2`; SGF activo não contém estas classes | Elegibilidade, conflitos, avaliações autorizadas e decisão continuam no host; adaptador do processo SGF não implementado | Recepção de cotações, grelha, empates, recomendação e decisão SGF pendentes | Testes locais não equivalem a transferência. Candidata não instalada; nenhuma adjudicação automática |
+| Abertura, admissibilidade, qualificação, recomendação e adjudicação | **Pendente** | Serviços/snapshots permanecem em Compras | Traduzir contexto, atribuições, impedimentos e versões sem alterar decisões concluídas | Actas, avaliações, decisões e ordens ainda não incorporadas | Necessita persistência e percurso completo por etapa, não simples ligação de menus |
+| Ordens, recepções, inventário físico e lotes | **Pendente** | O cálculo de reposição está transferido; movimentos, ledger físico, decisões e reversões não | Adaptar origens únicas, armazéns/artigos, separação de funções e âmbito empresarial | Guias, recepções, saídas, transferências, contagens e lotes SGF de Compras pendentes | Não duplicar efeitos físicos ou confundir projecção com stock disponível |
+| Fornecimento habitual, sugestões e importações | **Parcialmente transferido** | Apenas o motor de projecção incorporado; acordos, políticas versionadas, conversões e logística pendentes | Fontes, decisões, cadastros e vínculos aos processos SGF ainda por adaptar | Calendário, sugestão → requisição e importação operacional SGF pendentes | Um cálculo disponível não completa o workflow de abastecimento |
+| Equipamentos, peças, manutenção e investimento | **Pendente** | Serviços/cadastros de Compras ainda não incorporados | Correspondência de activos/peças/unidades e regras aprovadas por definir tecnicamente | Ecrãs/percursos de Compras no SGF pendentes | Preservar origens e não repetir consumo físico/custo |
+| Avaliação de fornecedores, melhoria e contraditório | **Pendente** | Versionamento, decisões e evidências permanecem em Compras | Adaptadores de intervenientes, conflitos e autorização independente pendentes | Preparação, revisão, acompanhamento e relatórios SGF pendentes | Não alterar avaliações antigas nem confundir qualificação com desempenho |
+| Relatórios de Compras e sínteses factuais | **Pendente** | Projecções autorizadas de Compras ainda não extraídas | Reutilizar âmbito efectivo e versões do processo no SGF | Relatórios individuais/consolidados do roteiro de Compras pendentes | Não substituir por relatórios financeiros SGF de outro âmbito; núcleo factual independente de IA |
+| Venda de mercadorias e ligação ao comercial SGF | **Pendente** | A opção de contexto não é um percurso completo de retalho/grosso demonstrado em Compras | Reutilizar responsabilidades de stock/facturação SGF, sem criar motores ou efeitos concorrentes | Percurso conjunto por módulos adquiridos ainda por demonstrar | Não confundir aquisição para revenda com venda efectuada nem declarar emissão fiscal transferida |
+| Pessoas: recrutamento e prestadores internos | **Pendente** | Processos e versões ainda em Compras; fronteira técnica de RH apenas preparada | Adaptar perfis/atribuições e integração futura com RH; apenas contrato formalizado pode desencadear ligação | Candidaturas, avaliações, decisões e formalização/RH no SGF pendentes | RH ainda não implementado; selecção aprovada/rascunho não criam vínculo |
+
+**Fora do âmbito SGF:** recrutamento e contratação de prestadores por conta de terceiros. Mantêm-se no SGC autónomo; não são uma pendência a implementar no SGF. Não transferir candidaturas automaticamente para Compras, vendas ou RH.
+
+### Percurso escolhido e candidata de requisições internas
+
+O proprietário autorizou **requisição interna → submissão → aprovação independente → consulta do processo**, usando a reserva orçamental existente. A implementação está preparada separadamente para revisão. **O SGF continua em 0.1.1; nem a candidata económica 0.1.2 nem esta integração foram activadas.** Não foram publicados pacotes nem alterados direitos públicos.
+
+O motor `packages/purchasing/src/InternalRequisitions.php` é usado pelo adaptador autónomo `app/Services/InternalRequisitionPort.php` e pela candidata `integrations/sgf/internal-requisitions`. Conserva as regras de valores, limites, reserva antes da submissão e decisão por identidade diferente do requerente e dos decisores anteriores. O adaptador SGF reutiliza `CompanyContext`, `Permissions` e `BudgetAdapter`; não copia utilizadores, empresas ou o motor orçamental.
+
+A candidata inclui rascunho/edição, listagem, submissão, decisão e consulta com histórico, além de parametrização versionada de centros/departamentos/intervenientes/limites. Apenas centros e pessoas autorizados do host podem ser associados. A configuração habilita a opção interna; não atribui permissões. O servidor volta a validar direitos e contexto em cada operação, incluindo repetições, e projecta as acções disponíveis para os ecrãs. Chave de operação, hash do conteúdo, versão esperada, locks e transacção única preservam origem e impedem duplicação de requisições/reservas em retry e concorrência. Uma falha mantém a transacção anterior; uma resposta perdida admite a repetição da mesma operação.
+
+Os artefactos de revisão são produzidos por `php scripts/build-internal-requisition-candidate.php`. O manifesto, hashes e caminhos estão em [proofs/internal-requisitions-candidate.json](proofs/internal-requisitions-candidate.json). `runtime.zip` contém código e schema explicitamente não instaláveis pelo updater actual; `verification.zip` contém as provas. Não levam bases, dados sintéticos, credenciais, dependências ou concessões. O caminho do SGF serve apenas para preparar/verificar; o runtime candidato utiliza ficheiros locais e serviços do próprio host. A futura instalação exige uma entrega compatível com schema, separada da activação do ponteiro de código.
+
+**Limitações da candidata:** SGF suporta neste recorte consumíveis, serviços e outros; equipamentos/peças e respectivas validações técnicas continuam apenas no percurso autónomo. Fornecedores, cotações, ordens e recepções não foram incorporados por arrasto. As rotas/ecrãs só são carregados pelo bootstrap explícito das provas; não há novo menu activo no destino. A integração do catálogo privado de permissões na administração comercial, a instalação/migração operacional e a parametrização comercial integral permanecem pendentes. A guarda isolada do adaptador orçamental SGF foi preservada. Não há integração de contratações para terceiros nem activação de RH.
+
+### Classificação das pendências para decidir a instalação
+
+**Incremento posterior de preparação dos acessos:** o catálogo e a autoridade inicial já têm implementação candidata em `integrations/sgf/access`, preparada separadamente em `ACCESS-CANDIDATE-a69f06e21747228a`. Prova [proofs/sgf-access-delivery.json](proofs/sgf-access-delivery.json): **24 controlos novos**, em PostgreSQL descartável, sem repetir as suites aprovadas da requisição. O SGF instalado continua com o catálogo anterior. As classificações abaixo mantêm-se bloqueantes **até integrar e instalar** esta preparação com o orçamento/schema/arranque operacionais; não significam ausência de implementação candidata.
+
+Conferência de código posterior à entrega, sem repetir testes aprovados e sem alterar a candidata congelada ou a instalação SGF. **A candidata comprova o percurso em ensaio isolado; ainda não é uma entrega operacional instalável.**
+
+| Pendência | Classificação para este percurso | Impacto e condição de resolução |
+| --- | --- | --- |
+| Equipamentos/peças e validações técnicas | **Fora do âmbito** do percurso básico entregue | O adaptador SGF aceita `consumable`, `service` e `other`. Estas requisições podem completar rascunho → submissão/reserva → aprovação → consulta sem equipamentos/peças. Uma necessidade de equipamento/peça continua sem percurso SGF suportado; não deve ser disfarçada como `other`. A classificação não declara suporte a todos os tipos de requisição. |
+| Catálogo administrativo das novas permissões e sua gestão | **Bloqueante para este percurso em utilização operacional** | `Permissions::UNIT` contém apenas `unit.view` e `unit.edit`; `IdentityWorkflow::accessList/changeAccess` filtra/valida esse catálogo. Os direitos `purchasing_requisitions.*` não podem ser atribuídos/revogados pela administração normal. As concessões directas dos testes foram apenas sintéticas e não constituem configuração comercial entregue. |
+| Autoridade inicial para gerir/outorgar os novos direitos | **Bloqueante para este percurso em utilização operacional** | `changeAccess` exige que quem concede já possua o direito concedido. Acrescentar nomes ao catálogo não resolve a atribuição inicial: é necessária entrega nominativa, explícita e auditada da autoridade aprovada pela empresa, sem outorga automática aos perfis actuais. A listagem e revogação de direitos derivados da unidade também devem abranger o catálogo integrado. |
+| Adaptador orçamental operacional | **Bloqueante para este percurso em utilização operacional** | `BudgetAdapter::guard()` exige ambiente `budget-testing`, PostgreSQL local e base `budget_core_sgf_<12hex>`. O adaptador de requisições chama essa guarda inclusive na consulta/configuração. Não basta copiar ficheiros ou permitir requisições sem orçamento. Substituir a composição exclusiva de ensaio por composição operacional configurada, mantendo autorização, isolamento e motor comum; não renomear base real nem mudar o ambiente para contornar a guarda. Os direitos orçamentais necessários também não integram o catálogo administrativo actual. |
+| Instalação/migração, arranque e encaminhamento das rotas | **Bloqueante para este percurso em utilização operacional** | `Schema::install` e bootstrap existem, mas só foram compostos no harness isolado. Falta instalador operacional com pré-condições, registo de migração e recuperação, composição local e registo controlado de rotas. O updater actual recusa esta candidata; não é possível instalar o percurso com o comando habitual de update de código. |
+| Fornecedores, cotações, ordens, recepções e contratações para terceiros | **Fora do âmbito** desta entrega | Não são necessários para terminar a requisição no estado aprovado e consultá-la; contratação para terceiros permanece excluída do SGF. |
+
+**Direitos que a instalação deve permitir gerir, sem os conceder implicitamente:** por empresa/unidade, `purchasing_requisitions.view`, `.prepare`, `.configure`, `.approve.department_head`, `.approve.procurement` e `.approve.finance`. Mantêm-se `company.view`, pertença à empresa e `unit.view` como condições do contexto. Submeter com reserva exige ainda `purchasing_budget.record`; rejeitar com libertação também usa esse direito existente. Preparação/aprovação da dotação usa os direitos do orçamento existente e deve ter responsáveis expressamente configurados. Só os escalões aplicáveis à política precisam de decisores, sempre com identidades distintas do requerente e dos decisores anteriores. Não é necessário conceder todos os direitos a todos os utilizadores.
+
+#### Implementação candidata do catálogo e autoridade inicial
+
+`php scripts/prepare-sgf-access.php ` lê os dois ficheiros de identidade do host e produz uma sobreposição de revisão em `storage/app/private/candidates/ACCESS-CANDIDATE-`, com manifesto e hashes antes/depois. **Não escreve na pasta SGF**, não aplica schema nem concede direitos. Recusa preparar uma alteração se os pontos esperados no host divergirem, exigindo reconciliação do trabalho em curso. A candidata original `IR-CANDIDATE-bfa807e2b3c4fae7` não foi reescrita.
+
+Na sobreposição, o catálogo unitário inclui os direitos de requisição e orçamento existentes; a criação de unidades continua a outorgar **apenas** `unit.view`/`unit.edit`. A administração nativa passa a listar/atribuir/revogar os novos direitos, conservando a exigência de o administrador possuir o direito que outorga. A atribuição unitária exige consulta da unidade pelo destinatário. Revogar a consulta da unidade confere autoridade sobre todos os direitos derivados afectados antes de os remover numa transacção; uma recusa não elimina parcialmente acessos.
+
+`InitialAuthority.php` resolve a atribuição inicial como operação explícita. Exige administrador autenticado com `access.manage`, consulta/pertença ao contexto e uma lista de âmbitos habilitados pela futura composição de instalação (`purchasing-access.initialisation_scopes`, chaves `tenant:company:unit`; vazia por omissão). Só admite como destinatários administradores nominativos já autorizados nessa empresa/unidade. Escolhem-se os direitos e um fundamento, sem selecção automática de todos os direitos. Não atribui perfis de desenvolvimento. A concessão e a auditoria nativa `purchasing.access_initialised` são atómicas, serializadas pela empresa. Uma falha de auditoria desfaz as concessões. Uma repetição exacta não concede de novo nem recupera direitos entretanto revogados; outra inicialização é recusada, seguindo-se a gestão normal de acessos. Revogar a autoridade administrativa bloqueia também a consulta do ecrã e a repetição da operação.
+
+As rotas candidatas em `integrations/sgf/access/routes.php` oferecem o ecrã `/purchasing/internal-requisitions/access-initialisation` e a operação `/api/internal-requisitions/access-initialisation`, com os middlewares nativos de autenticação, sessão e empresa. O ecrã mostra o contexto, permite escolher um destinatário nominativo e os direitos e envia CSRF/token de contexto/chave estável de operação. **Estas rotas não estão registadas no destino.** A prova nova verificou serviços nativos, persistência, autorização e renderização do ecrã; não declara ensaio HTTP completo ou revisão visual deste novo ecrã. A composição de instalação deverá ligar estas rotas ao módulo autorizado e retirar a habilitação de inicialização depois da entrega, preservando a auditoria.
+
+Próxima dependência concreta: preparar a composição orçamental operacional e o instalador rastreável, incorporar esta sobreposição ao pacote SGF específico e conferir a sua integração HTTP. Não activar o módulo, a candidata de acessos ou 0.1.2 por consequência desta implementação.
+
+### Procedimento de instalação: estado exacto e sequência obrigatória
+
+**Incremento posterior — composição orçamental e preparação de schema:** implementados em candidata separada `integrations/sgf/operational` e `scripts/sgf-internal-install.php`. Já existe executor de **preparação de schema**, com `plan` e `migrate`; continua sem existir comando completo de instalação/activação de código e rotas. Esta actualização sucede à ausência de executor registada na primeira conferência, preservando o âmbito das provas anteriores. Ver [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json). Nenhum destes comandos foi executado na base da instalação SGF de destino.
+
+`php scripts/prepare-sgf-operational.php ` lê o host e produz `OPERATIONAL-CANDIDATE-`, com manifesto, sobreposições de identidade/orçamento e ficheiros de suporte locais. Não escreve no SGF nem publica um pacote. A sobreposição orçamental conserva o motor e autorizações existentes; acrescenta composição operacional habilitada explicitamente, sem depender de `APP_ENV=budget-testing` ou de um nome de base QA. A guarda exige impressão do destino real (configuração e endereço/porta PostgreSQL efectivos), schema/versão de migração conferidos e âmbito `tenant:company:unit` autorizado em `purchasing-operational.scopes`. `purchasing-operational.enabled` é falso por omissão e `purchasing-operational.target` deve coincidir com o destino conferido. A guarda de ensaio anterior permanece para a composição QA, sem se tornar uma autorização operacional alternativa.
+
+A preparação exige fundação de identidade existente; cria o schema orçamental vazio se ausente, ou confere o schema completo existente contra o contrato estrutural aceite, conservando registos e dotação aprovada. Schema parcial ou incompatível é recusado. Estende os valores unitários aceites pelo constraint de permissões sem conceder direitos. Aplica o schema de requisições e grava `sgc_installations` imutável, revisão 1, assinatura estrutural, hash da migração, backup, operador nominativo de implantação e role PostgreSQL executante. Usa lock transaccional de instalação e DDL transaccional. Repetição com a mesma migração/schema reconhece a preparação anterior; alterações divergentes são recusadas sem sobrescrever. **Não há `down` nem eliminação de dados no executor.** A revisão actual não é mecanismo genérico para futuras migrações; estas exigem novo plano compatível e revisão própria.
+
+Procedimento da CLI **na candidata**, pelo PHP fixado do host, apenas depois da decisão de instalação e preservação do destino:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-internal-install.php" plan ""
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-internal-install.php" migrate "" "" ""
+```
+
+`plan` só lê/verifica: integridade dos ficheiros da candidata, compatibilidade dos ficheiros do host com hashes antes/depois, identidade do destino, hash do manifesto e existência do registo de preparação. O campo `schema_prepared` informa a existência da tabela de registo; não substitui a conferência integral feita por `migrate`/guarda operacional. Conferir o manifesto/hashes com a referência de entrega por canal de confiança. `migrate` exige correspondência do destino e do manifesto, ficheiro de backup existente e SHA-256 coincidente; lê o índice com `pg_restore --list` antes de escrever. O cliente PostgreSQL deve estar no PATH ou indicado por `PG_RESTORE_BINARY`. Conferir o índice não prova recuperação: manter o restauro/reconciliação já aceites e o ensaio adicional da preparação operacional registado na prova. Nenhuma credencial deve ser passada nos argumentos ou copiada para o manifesto; usa-se a ligação configurada do próprio host.
+
+**Prova deste incremento:** `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`, 20 controlos novos aprovados, incluindo CLI real, backup custom válido, recusa de backup não conferido, schema vazio/existente/parcial/incompatível, falha injectada, recuperação da transacção, repetição sem novo registo, ausência de concessões e preservação do orçamento aprovado. Após backup/restauro para outra base descartável fora de `budget_core_sgf_*`, a mesma composição passou em `production`, conferindo schema/registo de preparação, reserva existente e revogação preservada. Este ensaio verifica o novo registo/schema operacional; não repete nem substitui as suites já aprovadas da requisição. As duas bases e role restrita foram removidas; o backup sintético não integra a entrega. Os caminhos e resultados estão em [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json). Os ficheiros de código/ponteiro e dados do SGF instalado não foram alterados.
+
+Após `migrate`, o estado é **preparado, não activado**: não altera o ponteiro 0.1.1, providers/rotas, concessões, dados de demonstração ou configuração de activação. O código/sobreposições candidatos ainda precisam de aplicação controlada, composição de arranque e integração HTTP antes de propor a activação. A configuração comercial por empresa/unidade e a atribuição explícita de acessos continuam obrigatórias. Isto não entrega fornecedores, consumo de facturas, pagamentos, equipamentos/peças ou reorganização modular do SGF.
+
+**Não há neste momento um comando operacional completo para instalar esta candidata.** Os passos abaixo distinguem o que existe do que falta implementar; não são autorização para os executar no destino. A decisão de instalação continua separada. Não usar `purchasing:transfer`, `purchasing-release.php install`, os scripts de prova ou as fixtures para instalar esta integração numa empresa. O pacote `runtime.zip` é um artefacto de revisão, não deve ser extraído sobre a raiz do SGF: inclui também adaptações `app/*` de Compras que não pertencem ao host SGF.
+
+1. **Identificar e conferir — disponível:** fixar a candidata `IR-CANDIDATE-bfa807e2b3c4fae7`, o manifesto e os hashes em `proofs/internal-requisitions-candidate.json`; conferir o ZIP e cada ficheiro contra o manifesto. Registar raiz/destino, PHP do wrapper SGF, versão de aplicação/dependências, servidor/base/empresa/unidade e estado do ponteiro `packages/purchasing/current.json` (actualmente 0.1.1). Confirmar que os pré-requisitos de identidade e orçamento correspondem à composição pretendida. Nenhum comando desta etapa migra ou concede direitos.
+2. **Preparar a entrega operacional — bloqueado pelas implementações identificadas acima:** produzir pacote SGF separado e versionado apenas com os ficheiros comuns necessários e adaptadores/ecrãs do host; incluir migração rastreável, composição operacional do orçamento, catálogo/gestão dos direitos e mecanismo de autoridade inicial. Conservar os hashes da candidata como referência; uma alteração de código origina outro artefacto, sem sobrescrever o congelado. A migração 46 de Compras não se aplica à base SGF. Não copiar tabelas base nem fixtures de Compras.
+3. **Preservar o destino — procedimento necessário antes de qualquer futura escrita:** suspender tráfego de escrita e workers, aguardar transacções em curso, registar instante de corte, arquivar código/configuração de implantação e ponteiro/histórico; preservar documentos/ficheiros e contas. Fazer backup integral PostgreSQL da base conferida, não apenas das tabelas `ir_*`, pois o orçamento e as concessões também são afectados. Guardar o arquivo, hash e identidade do destino fora do pacote distribuído. Não incluir segredos no registo de implantação.
+4. **Aplicar schema e preparar código sem expor rotas — executor de schema agora em candidata:** usar a CLI `plan`/`migrate` descrita acima, sem outorgar acessos. O wrapper transaccional regista revisão, confere repetições e recusa divergências; não executar `Schema::install` isoladamente. Preparar os ficheiros num directório local de versão, sem sobrepor os `app/*` de Compras nem trocar para 0.1.2. A aplicação das sobreposições e o arranque/activação do código continuam separados e pendentes.
+5. **Configurar acessos e política — bloqueado pelo catálogo/autoridade inicial:** pelos mecanismos administrativos auditados, atribuir explicitamente os direitos aprovados a pessoas nominativas e ao âmbito correcto; garantir requerente e aprovadores distintos, e responsáveis da dotação. Depois configurar centros existentes, referências de departamento, intervenientes, limites, inclusão de imposto e exigência de orçamento. Nenhum nome ou ID de outra instalação é uma correspondência automática. Não inserir concessões por SQL como solução de entrega.
+6. **Activar — depende de decisão separada e das etapas anteriores concluídas:** registar arranque/rotas locais do módulo e abrir o percurso apenas para contextos/módulos adquiridos e utilizadores autorizados; reiniciar serviços persistentes pelo procedimento do host. Conferir o estado aplicado, acessos efectivos e a consulta do processo no destino, sem repetir as suites já aprovadas. Registar versão, schema, configuração, autoria e instante da activação. Só então reabrir escritas/workers. Não existe nesta entrega comando que concretize esta etapa; nenhum destes passos foi executado na instalação SGF.
+
+### Procedimento de recuperação por tipo de falha
+
+Os ensaios aceites sustentam DDL transaccional e backup/restauro isolado; não equivalem a um instalador de recuperação automática de cliente. Não repetir os testes para esta classificação.
+
+| Situação | Acção exacta |
+| --- | --- |
+| Falha de preparação, antes de escrever no destino | Abandonar a preparação; conservar o código/ponteiro 0.1.1 e a base intactos. Não há migração ou reversão de dados a executar. |
+| Excepção durante a transacção de schema | Fazer rollback da transacção; conferir ausência de tabelas/alterações parciais e igualdade com o inventário prévio. Se a ligação se perder durante o commit, primeiro inspeccionar schema e registo de revisão; não pressupor rollback nem repetir `Schema::install` às cegas. Manter o módulo desactivado até esclarecer o estado. |
+| Código não arranca após schema confirmado, antes de novas operações | Retirar o novo registo de rotas/arranque e regressar ao código/configuração anterior arquivados; reiniciar os serviços. Manter o schema aditivo e os dados, incluindo auditoria. O ponteiro 0.1.1 não reverte alterações do host. Não executar `down`, truncar `ir_*`, estreitar a coluna de permissões ou remover direitos/histórico por arrasto. |
+| Falha numa submissão/decisão ou resposta perdida | Conservar a identidade/contexto e repetir exactamente a mesma chave, versão e conteúdo; o recibo devolve o efeito já confirmado ou a transacção recomeça sem reserva duplicada. Se houver conflito de versão/conteúdo ou revogação, consultar o processo com identidade autorizada; não gerar uma nova origem para contornar o erro. |
+| Corrupção/dados ou schema incompatíveis após operações novas | Suspender escritas/workers; preservar também uma cópia do estado que falhou e operações posteriores ao corte. Restaurar o backup numa **nova base vazia**, nunca por cima da base em uso. Conferir tabelas/dados, schema/constraints/triggers e consulta de origens/versões/reservas. Reconciliar explicitamente as operações posteriores ao backup e a configuração/autorização actual, incluindo revogações; um backup antigo não deve reabrir acessos revogados. Só apontar a aplicação à base recuperada após conferência e decisão registada. Conservar a base anterior para diagnóstico. |
+
+Forma dos comandos PostgreSQL usados na prova aceite, a adaptar ao destino conferido e à autenticação administrativa local, **não executados nesta conferência**:
+
+```text
+pg_dump --format=custom --file= --dbname=
+pg_restore --list
+createdb --owner=
+pg_restore --no-owner --no-privileges --exit-on-error --dbname=
+```
+
+O restauro da prova usou uma role restrita proprietária da nova base. `--no-owner --no-privileges` não restaura roles/grants PostgreSQL: estes devem ser preservados no inventário administrativo e repostos explicitamente no ambiente de recuperação. As permissões funcionais da aplicação são dados da base e também exigem reconciliação temporal. `pg_restore --list` só lê o índice; a prova de recuperação aceite é o restauro efectivo com reconciliação das 22 tabelas e consulta posterior já registados. Os comandos não restauram documentos externos, `.env`, dependências ou código; estes têm cópias/procedimentos próprios. Não há comando único entregue para fazer a troca operacional da base.
+
+### Entrega distinta da reorganização modular do SGF
+
+Esta entrega é **a integração candidata de requisições internas**. Extrair um motor e preparar um adaptador não comprova a reorganização modular do SGF pedida posteriormente. **Essa reorganização continua pendente, sem resultado apresentado nesta entrega**, e deve ter inventário, alterações e resultado próprios. Não a contar como concluída na matriz nem usar esta classificação como autorização para a executar por arrasto.
+
+### Dados/schema: ensaio separado da reversão de código
+
+- **Compras:** migração aditiva `2026_11_01_000046_add_requisition_command_versions.php`, sobre a base que já contém a migração 45. Acrescenta versão do processo e recibos imutáveis de comando; não reclassifica nem apaga dados. O caminho autónomo anterior continua utilizável sem instalar os recibos; o ecrã só envia chaves/versões quando o schema está preparado. `down` recusa apagar recibos já existentes. Nenhuma migração foi aplicada à base persistente de desenvolvimento.
+- **SGF:** `Schema.php` acrescenta as tabelas `ir_*`, alarga o comprimento de `permission_grants.permission` e estende a condição de âmbito com os direitos privados da candidata. Conserva a condição anterior e os seus registos, sem conceder direitos. Políticas e histórico são versionados e imutáveis; snapshots submetidos e origem não são reescritos. A instalação foi ensaiada em PostgreSQL descartável já com identidades, concessões e orçamento sintéticos.
+- **Falha/compatibilidade:** DDL transaccional com falha injectada preserva schema/dados anteriores e permite nova tentativa. Foi ensaiada uma evolução aditiva compatível com a candidata anterior. Isto não comprova compatibilidade com futuras alterações destrutivas.
+- **Recuperação:** backup/restauro para outra base isolada, reconciliação de dados, colunas, constraints e triggers e consulta do processo restaurado. O backup de ensaio é sintético e não faz parte do pacote cliente. Uma recuperação real exige parar escritas e reconciliar operações posteriores ao backup; não há restauro automático sobre operações novas.
+- **Código:** o updater continua limitado a código sem migrações. Voltar ao código 0.1.1 não remove tabelas nem restaura dados, e essa versão não apresenta o novo percurso. Não executar `down` nem recuperar uma base antiga como efeito implícito de rollback do ponteiro.
+
+As provas e os resultados efectivos deste incremento constam de [proofs/internal-requisitions-sgf.json](proofs/internal-requisitions-sgf.json). As provas históricas abaixo conservam o respectivo âmbito. Ecrãs foram verificados por HTTP, modelo de acções, tipos/lint e compilação; não houve revisão visual por navegador.
+
+| Verificação da candidata congelada | Resultado |
+| --- | --- |
+| Integração SGF pelo PHP 8.4.27 do host, PostgreSQL descartável | 43 controlos; 2 origens, 2 requisições e 2 reservas, incluindo save/submit/decide concorrentes em processos separados |
+| Adaptador autónomo e regressão de compras/orçamento em PostgreSQL descartável | 46 testes / 339 asserções |
+| Regressão completa de Compras em SQLite isolado | 883 testes: 881 aprovados, 2 ignorados; 8.950 asserções; resultado final em [proofs/internal-requisitions-validation.json](proofs/internal-requisitions-validation.json) |
+| Recuperação de dados/schema | 22 tabelas reconciliadas; constraints/triggers/colunas conferidos; consulta restaurada preserva origem e versão 3 |
+| Modelo dos ecrãs congelados, limites monetários exactos, envelopes de retry e headers | 23 controlos Node; sem navegador |
+| PHPStan dirigido, TypeScript, lint/formatação e build frontend | Aprovados; sem erros PHPStan ou avisos lint nos ficheiros verificados |
+| Activação e permissões no SGF de destino | Ponteiro 0.1.1 preservado; candidata recusada pelo updater normal; nenhuma concessão aplicada |
+
+## Percurso de actualização
+
+### Separação entre preparação e instalação cliente
+
+| Etapa | Acesso necessário | Dependência de Compras na instalação cliente |
+| --- | --- | --- |
+| Produzir ZIP validado (`publish-purchasing.php`) | Fonte Compras e canal de saída; não precisa de pasta SGF | Nenhuma depois de produzido o artefacto |
+| Coordenar prova/instalação no SGF de desenvolvimento (`transfer-purchasing.php`) | Pastas locais de Compras/SGF, wrapper e canal | É uma ferramenta de desenvolvimento, não o instalador obrigatório do cliente |
+| Conferir/instalar no cliente (`purchasing-release.php`) | SGF do próprio cliente com dependências e ferramentas de arranque, ZIP e SHA-256 aprovado | Não usa pasta, base, sessão ou serviço de Compras |
+| Executar a capacidade instalada | Loader, versão seleccionada e dependências locais do SGF | Não consulta a origem nem sincroniza dados com ela |
+
+Conferido por dois meios: teste em pasta cliente sintética com a pasta de origem renomeada para indisponível; e execução no SGF `0.1.1` verificando que todos os ficheiros PHP carregados pertencem ao próprio host. A segunda prova não arranca a aplicação nem liga à base. Isto comprova a independência do pacote actual, não a preparação completa de todos os futuros módulos de cliente.
+
+No desenvolvimento, a partir de Compras:
+
+```text
+composer purchasing:transfer -- C:/laragon/www/sgf-tempo C:/laragon/data/sgc-releases/purchasing
+```
+
+O comando usa o wrapper e o PHP fixado pelo SGF, sem substituir versões. Executa regressões da capacidade e do mecanismo; verifica que a origem não mudou durante os testes; prova uma fotografia imutável; publica essa mesma fotografia; compara com a versão instalada; prova a candidata com o PHP/dependências do SGF; instala se for mais recente. Sem alterações no pacote, não gera versão nem reinstala código. O exemplo de caminho é do ambiente local, não dependência do runtime ou do formato distribuído.
+
+Para apenas produzir uma versão validada, usar `composer purchasing:publish -- `. O comando `scripts/purchasing-release.php publish` é a primitiva técnica sem execução de testes; a publicação normal usa o percurso validado anterior.
+
+O canal contém `catalog.json` e ZIPs imutáveis. Há numeração patch automática por alteração dos bytes; `source_sha256` identifica o conjunto exacto de ficheiros. O manifesto individual contém SHA-256 por ficheiro e contrato/capacidades do módulo. A validação rejeita ficheiros extra, ausentes, alterados, caminhos inválidos, entradas duplicadas e arquivos excessivos.
+
+Na instalação cliente, distribuir o ZIP, os artefactos de arranque e a referência SHA-256 por canal de confiança. Não é necessário o checkout de Compras:
+
+```text
+php scripts/purchasing-release.php check
+php scripts/purchasing-release.php install
+php scripts/purchasing-release.php status
+php scripts/purchasing-release.php rollback
+```
+
+O SHA-256 confere integridade contra a referência aprovada; não autentica sozinho um canal remoto. Não há download ou activação a partir de URLs arbitrários. Um catálogo remoto assinado e verificação de assinatura ainda não integram esta entrega.
+
+## Preservação e recuperação
+
+Cada versão ocupa `packages/purchasing/releases/`; `current.json` é o ponteiro local. A instalação valida a versão actual antes de actualizar, prepara a candidata separadamente, regista histórico e troca o ponteiro atomicamente sob lock. Não apaga a versão anterior. Uma alteração local ao código instalado bloqueia a actualização em vez de a sobrescrever. Versão igual com conteúdo diferente e downgrade implícito são recusados. A reversão explícita confere os bytes anteriores e não recalcula nem apaga dados.
+
+`current.json`, locks e histórico são estado da instalação e não credenciais/contas distribuídas. O SGF pode arrancar sem este módulo opcional instalado; depois da instalação, o provider carrega o pacote verificado. O histórico é local e não deve ser transplantado de uma empresa para outra.
+
+O formato actual **recusa pacotes com alterações de schema ou concessões**. Os comandos de instalação/prova do primeiro incremento não abriram bases nem executaram migrações, seeds, alterações de contas, permissões, stock, finanças ou contratos. A nova candidata de requisições ensaiou persistência apenas em bases sintéticas descartáveis, conforme secção própria, sem alargar o contrato do updater. Serviços persistentes devem ser reiniciados pelo procedimento operacional depois de actualizar; o instalador não termina processos.
+
+### Alcance exacto da reversão
+
+| Elemento | Cobertura actual |
+| --- | --- |
+| Código do pacote e versão seleccionada | Reversão explícita para a versão anterior, depois de conferir integridade; histórico local e troca de ponteiro |
+| Dependências PHP/Composer e frontend do host | Não são substituídas nem revertidas por este mecanismo |
+| Dados empresariais, schema, configuração e permissões | Não são alterados nem revertidos pelo actual contrato |
+| Migrações de dados/schema | Fora do updater; na candidata interna, DDL falhada, compatibilidade aditiva e backup/restauro em base isolada ensaiados separadamente; sem down/restauro automático |
+
+O manifesto exige `database_changes=false` e o formato não aceita pastas de migração/seed; isso delimita o contrato distribuído actual, não torna PHP futuro uma sandbox. Uma capacidade com persistência exige antes um contrato de migração com autoria/versão, schema compatível, aplicação idempotente, auditoria, destino conferido e backup/restauro ensaiados em isolamento. Deve distinguir reversão de código compatível, correcção por migração seguinte e eventual restauro de dados com plano de reconciliação. Não executar automaticamente `down`, apagar históricos ou restaurar um backup antigo sobre novas operações. A estratégia concreta terá de acompanhar o percurso escolhido.
+
+## Provas e limites
+
+**Conferência adicional antes de ampliar:** seis testes do mecanismo/29 asserções, incluindo execução em cliente com origem indisponível; prova SGF em PHP 8.4.27, versão activa `0.1.1`, todos os ficheiros carregados no próprio host e nenhuma ligação à base. Ver `proofs/purchasing-client-independence.json`. As provas do primeiro incremento abaixo mantêm o seu âmbito histórico. A candidata `0.1.2` contém preparação económica, mas não foi instalada; não contar os testes dessa preparação como prova do processo económico no SGF.
+
+- Compras: 86 testes/566 asserções no percurso de publicação (reposição, histórico, lotes, coordenação, sugestões, estornos, contagens e cinco testes do mecanismo).
+- SGF: dois testes/11 asserções, executados com o wrapper e PHP 8.4.27, sem base; execução real do pacote e resultados de consumo manual, média racional e lotes.
+- Publicação `0.1.0` e actualização `0.1.1` instaladas localmente. A segunda versão acrescenta documentação do contrato de distribuição; o algoritmo transferido permaneceu igual.
+- Reversão real `0.1.1 → 0.1.0` e reactivação `0.1.1` conferidas, sem dados/permissões alterados.
+- Cinco testes do instalador cobrem publicação sem duplicação, nova versão, retry, rollback/reactivação, código local alterado, manifesto errado, conteúdo adulterado, path traversal, ficheiro ausente, segredo na origem e pedido de alteração de schema.
+- Fachada e motor comum passaram análise PHP dirigida. Fonte do mecanismo e artefactos SGF conferidos em `packages/module-updates/runtime-manifest.json`.
+
+**Limitação central:** este mecanismo actualiza as capacidades já extraídas para o pacote; não transfere automaticamente melhorias ainda confinadas aos controladores/serviços/ecrãs de Compras. Continuar a migração pela matriz acima, sem declarar todo o SGF actualizado só porque a versão do pacote mudou. Não há publicação no domínio ou conclusão de integração de RH nesta entrega.
diff --git a/docs/SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md b/docs/SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md
new file mode 100644
index 0000000..7ce51c5
--- /dev/null
+++ b/docs/SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md
@@ -0,0 +1,85 @@
+# Reversão integral de afectações — entrega técnica, 30/10/2026
+
+Incremento `COMMON-ALLOCATION-FULL-REVERSAL-SIM-v1`, sobre os checkpoints aceites SGF `e4f520092e8aa3261880b8b91fca0fdf789086ce` e Compras `13a6cb9922af5004c636697c49f229d4a97f231c`. Esses checkpoints e provas anteriores foram preservados. Esta entrega não presume aceitação e não cria novo checkpoint.
+
+## Responsabilidade e comportamento
+
+Único motor: `packages/payables/src/SupplierSettlements.php`. O cálculo canónico de Payables continua a chamar `allocated()`, agora com afectações confirmadas menos compensações integrais confirmadas. O disponível do pagamento segue a mesma regra. Não se escreve um segundo saldo nem se alteram principal, impostos, documento, pagamento, afectação ou decisões originais.
+
+- `reversal-prepare`: recebe ID estável da afectação, versão da origem e motivo obrigatório. Cria uma proposta única por afectação, sem efeito monetário.
+- `reversal-confirm`: recebe ID da proposta e motivo obrigatório da decisão. Exige outra identidade **e outra pessoa**, revalida contexto, direitos e versão, e acrescenta o movimento compensatório integral ligado ao original. Não cancela o pagamento.
+- A nova afectação usa os comandos anteriores e uma nova referência. Revalida fornecedor, moeda, versão, saldo da obrigação e disponível do pagamento. A ligação original revertida não impede a nova ligação.
+- Referências antigas e suas repetições recuperam o resultado original, incluindo depois da reversão; não reaplicam o efeito revertido. Conteúdo incompatível gera conflito. Uma proposta com o mesmo plano pode existir num novo ciclo após reversão, sem confundir repetição com uma nova correcção.
+- O ciclo é por pagamento e corresponde à última compensação confirmada. Uma proposta de afectação ainda não decidida de um ciclo anterior não pode ser confirmada depois de uma reversão; exige nova preparação/referência. Decisões originais já confirmadas continuam recuperáveis sem novo efeito.
+- Motivo e autoria da preparação/confirmação ficam em registos imutáveis; eventos e outbox são gravados na mesma transacção dos comandos e movimentos. Confirmação concorrente recupera o mesmo movimento.
+
+O motor continua a receber actor, pessoa, organização e callbacks explícitos. As permissões/contextos dos hosts continuam nos adaptadores existentes: SGF `app/Modules/Purchasing/SupplierPayables.php`; Compras `app/Services/SupplierPayables.php`. Não foram equiparados tenant, empresa ou unidade. Não há dependência de facturação de vendas ou de um SGF remoto.
+
+## Interfaces e direitos
+
+`SettlementHttp.php` expõe a consulta das propostas/compensações e verifica o utilizador funcional do host. `ui/settlements.php`, a mesma página nas duas composições, acrescenta a secção «Reversão integral e nova afectação», com motivo, confirmação independente, montante integral, ligação ao original, histórico e explicação de que o pagamento continua confirmado. O formulário não permite indicar um montante de reversão. A consulta não recebe formulários de escrita. O servidor revalida tudo, mesmo que o cliente seja alterado.
+
+As duas `routes/web.php` permitem apenas os dois novos comandos nos endpoints autenticados existentes. Novas capacidades explícitas: `reversal_prepare` e `reversal_confirm`; não derivam automaticamente dos direitos de afectação. Só foram outorgadas às identidades sintéticas de **bases descartáveis** pelo instalador de prova. Nenhum direito público, real ou das demonstrações persistentes foi modificado. Página inicial preservada sem cartões/resumos.
+
+## Actualização de esquema e preservação
+
+`src/ReversalSchema.php` é uma actualização QA explícita e transaccional, não uma migração operacional registada:
+
+1. Acrescenta `ps_reversal_proposals` e `ps_reversals`, com unicidade por afectação, chaves estrangeiras e triggers append-only.
+2. Um trigger de integridade exige compensação do montante inteiro, pagamento/obrigação/contexto/versão correspondentes e confirmação por actor/pessoa diferentes do preparador.
+3. Acrescenta `ps_proposals.allocation_cycle`, com zero para os registos anteriores. Substitui a unicidade pagamento/plano por pagamento/plano/ciclo.
+4. Substitui a unicidade pagamento/obrigação da afectação pela unicidade decisão/obrigação. A regra de uma ligação activa por pagamento/obrigação mantém-se no motor, dentro do bloqueio organizacional e do pagamento. Só uma reversão confirmada permite nova ligação.
+
+`scripts/reversals-install.php` recusa as bases das demonstrações persistentes, utiliza apenas settings de prova e compara todas as colunas anteriores das nove tabelas antes/depois. As linhas originais coincidiram nas duas composições. O código conserva compatibilidade com o esquema anterior, sem compensações: nesse caso a interface mostra que a actualização ainda não foi aplicada e não oferece os novos formulários.
+
+**Não aplicada em 8005/8006, desenvolvimento ou produção.** Para esses destinos a mudança fica proposta separadamente: inventário e backup/restauro, conferência do esquema exacto, aplicação explícita e outorga dos dois direitos em contexto próprio. Não reverter o esquema simplesmente recriando as unicidades antigas depois de existirem novos ciclos; preservar o ledger e planear recuperação pelo backup ou correcção progressiva. Um binário antigo que desconheça compensações não pode calcular saldos de uma base que já tenha reversões: a versão do pacote e a do esquema têm de ser transportadas em conjunto. Os checkpoints anteriores continuam válidos para os seus esquemas/dados originais. Não há migração de dados reais nesta entrega.
+
+## Provas automáticas
+
+| Etapa | Dívida Compras | Dívida SGF | Pagamento por afectar |
+| --- | --- | --- | --- |
+| Principal reconhecido | 11 600 MZN | 11 600 MZN | — |
+| Pagamento de 4 000 confirmado, antes de afectar | 11 600 MZN | 11 600 MZN | 4 000 MZN |
+| Afectação original de 4 000 confirmada | 7 600 MZN | 7 600 MZN | 0 MZN |
+| Reversão proposta | 7 600 MZN | 7 600 MZN | 0 MZN |
+| Reversão integral confirmada | 11 600 MZN | 11 600 MZN | 4 000 MZN |
+| Nova afectação de 3 000 proposta | 11 600 MZN | 11 600 MZN | 4 000 MZN |
+| Nova afectação de 3 000 confirmada | 8 600 MZN | 8 600 MZN | 1 000 MZN |
+
+Vencimento e dias de atraso permanecem `null`; atraso indeterminado. Base 10 000, imposto documental 1 600 e principal 11 600 preservados, sem recálculo ou nova regra fiscal.
+
+Evidências novas, sem substituir provas aceites:
+
+- `proofs/supplier-reversals-baseline-20261030.json`: percurso anterior HTTP/HTML no código actualizado e esquema anterior, nas duas composições.
+- `proofs/supplier-reversals-20261030.json`: compensação/correcção, repetição com mesma/nova chave, dupla reversão sem novo movimento, conteúdo incompatível, motivo em falta, payload parcial recusado, identidades e pessoas distintas, permissões, revogação durante espera, outra empresa, versão antiga/origem alterada, falha antes do evento e recuperação com a mesma chave, saldo disponível, novo ciclo e repetição das referências/decisões antigas, reentrada e histórico.
+- Quatro corridas PostgreSQL por host com dois processos e duas esperas observadas: preparação repetida, confirmação com revogação, confirmação repetida e nova afectação repetida. Não é prova de carga ou de todos os escalonamentos.
+- Integridade SQL: tentativas de update/delete dos dois novos tipos de registo recusadas; compensações com montante parcial, autor igual ou unidade incompatível recusadas. Originais de pagamento/decisão/afectação/principal comparados por hash. Um evento/outbox por acontecimento; um único movimento de reversão.
+- `proofs/supplier-reversals-legacy-regression-20261030.json`: regressão adversarial dos pagamentos/afectações anteriores, incluindo corridas, isolamento, limites e recuperação, sem regravar a prova aceite.
+- `proofs/supplier-reversals-verification-20261030.json`: resumo das verificações finais. Baseline no esquema antigo e regressão adversarial no esquema actualizado aprovados; os três artefactos correspondem ao SHA-256 final do mesmo núcleo `4a719ae1456df974877e2555dd6d559f26ebc40b90ae3ff7e11874cfbf2f106b`. Oito corridas de reversão/correcção e doze de regressão financeira, com processos/ligações separados. Os runners terminaram com remoção das suas bases, roles e processos.
+
+Regressões de Compras: **81 testes aprovados, 343 asserções**, SQLite em memória, nos cinco ficheiros SupplierPayableIntegrationTest, ExternalPaymentTest, InvoiceTest, PayableHistoryImportTest e PayablePaymentDecisionTest. PHPStan das três classes comuns alteradas sem erros; Pint aprovado. Builds SGF e Compras aprovados. Regressão de navegação/página inicial aprovada. A suite SGF que recria a base persistente não foi executada: os comandos SGF foram exercitados nos hosts PostgreSQL descartáveis. Sem dependências novas ou alterações de versões.
+
+Uma asserção antiga do script adversarial ainda esperava oferecer um pagamento totalmente afectado no formulário; foi ajustada para a regra de interface já entregue que o omite. Não se alterou o motor para satisfazer essa asserção.
+
+## Repetição e revisão local
+
+Ficheiros alterados em SGF: `packages/payables/bootstrap.php`, `src/SupplierSettlements.php`, `src/SettlementHttp.php`, `ui/settlements.php`, `routes/web.php`, `scripts/settlements-interface-run.py`, `settlements-interface-check.mjs`, `settlements-http-proof.mjs`, `settlements-probe.php` e documentação. Novos: `packages/payables/src/ReversalSchema.php`, `scripts/reversals-install.php`, `scripts/reversals-http-proof.mjs`, este relatório e as quatro provas acima. Em Compras: `routes/web.php`, `docs/desenvolvimento.md` e `docs/orientacoes-continuidade.md`. Os adaptadores financeiros e o motor de obrigações Payables.php não foram duplicados nem alterados.
+
+A comparação com o checkpoint e4f5200 confirmou que as três provas aceites supplier-settlements.json, supplier-settlements-interface-20261030.json e supplier-settlements-consolidation-20261030.json mantêm o conteúdo anterior. Alterações alheias de documentação/publicação de candidatos em Compras foram preservadas.
+
+Na pasta SGF, executar com os binários existentes:
+
+```powershell
+& 'C:/laragon/bin/python/python-3.10/python.exe' scripts/settlements-interface-run.py --reversals
+& 'C:/laragon/bin/python/python-3.10/python.exe' scripts/settlements-interface-run.py --adversarial --upgraded
+```
+
+O runner exige 8007/8008 livres, cria apenas as suas bases/roles descartáveis e remove-as no finally. Não pára 8005/8006. Para manter as interfaces de prova abertas para revisão local, acrescentar `--review` ao primeiro comando; Compras `http://127.0.0.1:8007/demo?next=payments`, SGF `http://127.0.0.1:8008/demo?next=payments`. Ctrl+C termina os servidores e remove os dados dessa execução. As funções sintéticas são Preparação, Validação e Consulta. Depois das provas o cenário estará em 8 600/1 000; o histórico mostra as etapas. Não há credenciais no relatório.
+
+## Limitações separadas
+
+**Visual:** pendente, sem tentativa de navegador nesta entrega, por instrução expressa. HTTP/HTML, sintaxe JS e VM não comprovam cliques, foco, scroll, legibilidade ou ecrã estreito. O bloqueio anterior de política de URL do navegador Windows não foi contornado.
+
+**MFA:** os dois ignorados anteriores, `users with two factor enabled are redirected to two factor challenge` e `security page is displayed`, continuam pendentes. Não estão nos 81 testes afectados executados agora e não constituem falhas do cálculo financeiro provado.
+
+**Âmbito:** apenas reversão integral de uma afectação confirmada e nova afectação. Sem reversão parcial, cancelamento do pagamento, operação bancária, contabilidade, reconciliação, stock/orçamento adicional, importação de históricos ou habilitação pública. A proposta de reversão fica pendente até confirmação; não foi acrescentado circuito de rejeição/cancelamento da proposta. As demonstrações persistentes não receberam a actualização QA. Sem publicação, push ou checkpoint de encerramento. Falta revisão/aceitação do responsável; não se presume aceitação visual ou técnica deste incremento.
diff --git a/docs/SUPPLIER_SETTLEMENT_DELIVERY.md b/docs/SUPPLIER_SETTLEMENT_DELIVERY.md
index c755086..8ee5ac0 100644
--- a/docs/SUPPLIER_SETTLEMENT_DELIVERY.md
+++ b/docs/SUPPLIER_SETTLEMENT_DELIVERY.md
@@ -4,6 +4,8 @@ Implementado o incremento seguinte às obrigações, nas duas composições, com
## Responsabilidade e código
+Incremento posterior, ainda sem aceitação: [reversão integral e nova afectação](SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md). Substitui apenas a exclusão de desenvolver reversões integrais neste ensaio; mantém os checkpoints aceites, a revisão visual pendente e a ausência de habilitação pública. As demonstrações persistentes não foram actualizadas por consequência deste desenvolvimento.
+
Actualização de 30/10/2026: [consolidação automática sem navegador](SUPPLIER_SETTLEMENT_CONSOLIDATION_20261030.md). Complementa as provas abaixo, sem as substituir; cobertura adicional de repetição da confirmação, identidade distinta e eventos únicos aprovada nas duas composições. Revisão visual continua pendente.
O código comum reside em `packages/payables/src/SupplierSettlements.php`, `SettlementSchema.php`, `SettlementHttp.php` e `ui/settlements.php`. Carregamento e apresentação em `bootstrap.php` e `src/Workspace.php`. `src/Payables.php` conserva a responsabilidade única pelo saldo: principal menos histórico confirmado existente e afectações novas confirmadas. Não se escrevem pagamentos externos legados para representar novamente o mesmo facto.
diff --git a/docs/proofs/internal-requisitions-candidate.json b/docs/proofs/internal-requisitions-candidate.json
new file mode 100644
index 0000000..f819177
--- /dev/null
+++ b/docs/proofs/internal-requisitions-candidate.json
@@ -0,0 +1,17 @@
+{
+ "candidate_id": "IR-CANDIDATE-bfa807e2b3c4fae7",
+ "directory": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7",
+ "manifest_sha256": "dee8e030763b6cc277059d91497f66c02233630e8416e7417733e9d51f471b9c",
+ "archives": {
+ "runtime": {
+ "file": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7/runtime.zip",
+ "sha256": "5a63a283a21c9c8a7fdd837da8a0725cfe28cd2653eac61884e8b64f30c38326"
+ },
+ "verification": {
+ "file": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7/verification.zip",
+ "sha256": "02776c0097ab27fcb6269e647ce6b10c04a6731be56de54ceac2890e7bbac325"
+ }
+ },
+ "standard_updater_refuses_candidate": true,
+ "activation_performed": false
+}
\ No newline at end of file
diff --git a/docs/proofs/internal-requisitions-sgf.json b/docs/proofs/internal-requisitions-sgf.json
new file mode 100644
index 0000000..5606189
--- /dev/null
+++ b/docs/proofs/internal-requisitions-sgf.json
@@ -0,0 +1,85 @@
+{
+ "passed": true,
+ "checks": [
+ "fresh isolated database",
+ "failed additive migration rolls back preserving existing identity grants and budget",
+ "successful additive migration preserves existing data",
+ "authenticated synthetic identity 1",
+ "authorised company unit selection 1",
+ "authenticated synthetic identity 2",
+ "authorised company unit selection 2",
+ "versioned policy without functional grants",
+ "authorised workflow screen and context",
+ "create draft through HTTP",
+ "CSRF retained on candidate commands",
+ "create retry one origin",
+ "protected context field refused",
+ "wrong work area refused",
+ "stale context refused on reads",
+ "stale edit refused",
+ "failure after reserve injected",
+ "failure rolls back origin reserve steps and request",
+ "committed response loss injected",
+ "exact retry recovers committed submission",
+ "single existing budget reservation",
+ "self approval denied with approval grant",
+ "revoked approval denied at server",
+ "independent approval",
+ "approval retry preserves one decision and versions",
+ "authorised origin version and history consultation",
+ "revoked read denied",
+ "unit selection does not grant access",
+ "explicit authorised unit switch",
+ "read and operation from previous unit blocked",
+ "explicit authorised company switch without name matching",
+ "other company cannot read origin",
+ "return to original authorised scope",
+ "first transaction owns scope lock save",
+ "concurrent retry serialized without duplicate save",
+ "first transaction owns scope lock submit",
+ "concurrent retry serialized without duplicate submit",
+ "first transaction owns scope lock decide",
+ "concurrent retry serialized without duplicate decide",
+ "concurrent whole lifecycle produced one request reserve per origin",
+ "history SQL immutability",
+ "failed schema transaction recovered",
+ "old candidate reads additive compatible schema without rewriting data"
+ ],
+ "concurrency": [
+ "save",
+ "submit",
+ "decide"
+ ],
+ "scope": {
+ "tenant_id": 1,
+ "company_id": 2,
+ "unit_id": 2
+ },
+ "requests": 2,
+ "budget_reservations": 2,
+ "schema_failure_recovered": true,
+ "additive_schema_compatible": true,
+ "active_destination_changed": false,
+ "data_schema_recovery": {
+ "backup_sha256": "c283b6fdcc6b18c9455bab721790f640235a9b87b5aaa9846a6ed40cd04c9b0f",
+ "tables_reconciled": 22,
+ "restore_verified": true,
+ "schema_constraints_and_triggers_verified": true,
+ "restored_consultation": {
+ "restored_candidate_consultation": true,
+ "id": 1,
+ "origin_preserved": true,
+ "version": 3
+ },
+ "restores_new_operations_automatically": false,
+ "separate_from_code_rollback": true
+ },
+ "source_postgres_tests": {
+ "passed": true,
+ "output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":46,\"passed\":46,\"assertions\":339,\"duration_ms\":23459}"
+ },
+ "checked_at": "2026-11-01T13:56:52+00:00",
+ "isolated_database_removed_in_finally": true,
+ "active_destination_version": "0.1.1",
+ "candidate_root": "C:\/laragon\/www\/compras\/storage\/app\/private\/candidates\/IR-CANDIDATE-bfa807e2b3c4fae7"
+}
\ No newline at end of file
diff --git a/docs/proofs/internal-requisitions-validation.json b/docs/proofs/internal-requisitions-validation.json
new file mode 100644
index 0000000..fd4450d
--- /dev/null
+++ b/docs/proofs/internal-requisitions-validation.json
@@ -0,0 +1,58 @@
+{
+ "candidate_id": "IR-CANDIDATE-bfa807e2b3c4fae7",
+ "checked_date": "2026-11-01",
+ "source_full_sqlite_suite": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 883,
+ "passed": 881,
+ "assertions": 8950,
+ "duration_ms": 171488,
+ "skipped": 2
+ },
+ "source_postgresql": {
+ "tests": 46,
+ "assertions": 339,
+ "passed": true,
+ "isolated": true
+ },
+ "sgf_candidate": {
+ "checks": 43,
+ "passed": true,
+ "isolated": true,
+ "proof": "internal-requisitions-sgf.json"
+ },
+ "screen_logic": {
+ "checks": 23,
+ "passed": true,
+ "browser_used": false
+ },
+ "phpstan": {
+ "errors": 0,
+ "passed": true,
+ "scope": "InternalRequisitionPort, RequisitionWorkflow, Requisition model and shared InternalRequisitions"
+ },
+ "typescript": {
+ "passed": true
+ },
+ "frontend_lint": {
+ "passed": true,
+ "files": 4,
+ "warnings": 0
+ },
+ "frontend_build": {
+ "passed": true
+ },
+ "candidate": "internal-requisitions-candidate.json",
+ "destination_version": "0.1.1",
+ "activated": false,
+ "published": false,
+ "public_rights_changed": false,
+ "data_schema_recovery_separate_from_code": true,
+ "limitations": [
+ "SGF equipment/spare-part technical workflows not incorporated",
+ "Operational migration installation and administrative permission catalogue not activated",
+ "No browser visual review; HTTP, screen logic and compilation verified",
+ "Two tests skipped in full suite; not counted as passed"
+ ]
+}
diff --git a/docs/proofs/modular-navigation-completion.json b/docs/proofs/modular-navigation-completion.json
new file mode 100644
index 0000000..c02e971
--- /dev/null
+++ b/docs/proofs/modular-navigation-completion.json
@@ -0,0 +1,97 @@
+{
+ "date": "2026-11-01",
+ "synthetic_only": true,
+ "regression": {
+ "tests": 185,
+ "assertions": 3220,
+ "errors": 0,
+ "failures": 0,
+ "skipped": 0
+ },
+ "focused": {
+ "tests": 8,
+ "assertions": 90,
+ "errors": 0,
+ "failures": 0,
+ "skipped": 0
+ },
+ "focused_rerun_reason": "Development configuration and isolated discovery guard changed; one protection case added.",
+ "development": {
+ "url": "http://127.0.0.1:8039",
+ "database": "sgf_modules_dev_2576427d1939",
+ "role": "sgf_modules_qa_2576427d1939",
+ "port": 55439,
+ "profiles": [
+ {
+ "role": "preparador",
+ "modules": [
+ "commercial",
+ "purchasing",
+ "stock",
+ "workshop",
+ "finance",
+ "accounting"
+ ],
+ "prepare": true,
+ "validate": false,
+ "access_manage": true
+ },
+ {
+ "role": "revisor",
+ "modules": [
+ "commercial",
+ "purchasing",
+ "stock",
+ "workshop",
+ "finance",
+ "accounting"
+ ],
+ "prepare": false,
+ "validate": true,
+ "access_manage": false
+ },
+ {
+ "role": "consulta",
+ "modules": [
+ "commercial",
+ "purchasing",
+ "stock",
+ "workshop",
+ "finance",
+ "accounting"
+ ],
+ "prepare": false,
+ "validate": false,
+ "access_manage": false
+ }
+ ],
+ "isolation": {
+ "pending_record_exists": true,
+ "association_events": 0,
+ "development_cannot_connect_regression": true,
+ "regression_cannot_connect_development": true
+ }
+ },
+ "installed_preservation": {
+ "preserved": true,
+ "checks": {
+ "permission_grants": true,
+ "company_memberships": true,
+ "identity_audit": true,
+ "guard": true,
+ "purchasing_pointer": true
+ }
+ },
+ "visual_review": {
+ "status": "pending",
+ "browser_control_retried": false
+ },
+ "published": false,
+ "purchasing_candidate_activated": false,
+ "credentials_included": false,
+ "artifacts": {
+ "runtime": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939",
+ "regression_junit_sha256": "1fad15650dfce2fe6f459275b1ef55706b4aa75124b735e2ace61ba88d75660b",
+ "focused_junit_sha256": "b542242aec515ff31d09ece924e3b5e72919d06f3ffd190d15a423cbd5c2b506"
+ }
+}
diff --git a/docs/proofs/purchasing-client-independence.json b/docs/proofs/purchasing-client-independence.json
new file mode 100644
index 0000000..db93c76
--- /dev/null
+++ b/docs/proofs/purchasing-client-independence.json
@@ -0,0 +1,23 @@
+{
+ "checked_at": "2026-11-01T12:58:15.7857457Z",
+ "installed_client": {
+ "version": "0.1.1",
+ "runtime": "8.4.27",
+ "all_loaded_code_in_client_host": true,
+ "source_checkout_required": false,
+ "database_connected": false,
+ "quantity_milli": 84000
+ },
+ "isolated_test": {
+ "assertions": 29,
+ "source_directory_renamed_unavailable": true,
+ "all_child_loaded_code_in_synthetic_client": true,
+ "tests_passed": 6
+ },
+ "rollback_scope": "verified code pointer only",
+ "schema_or_data_rollback_implemented": false,
+ "future_migrations_verified": false,
+ "active_sgf_version": "0.1.1",
+ "prepared_not_installed": "0.1.2",
+ "expansion_interrupted_before_activation": true
+}
\ No newline at end of file
diff --git a/docs/proofs/purchasing-transfer.json b/docs/proofs/purchasing-transfer.json
new file mode 100644
index 0000000..1bbf76f
--- /dev/null
+++ b/docs/proofs/purchasing-transfer.json
@@ -0,0 +1,36 @@
+{
+ "checked_at": "2026-11-01T12:37:11.1059289Z",
+ "scope": "first purchasing capability and release update mechanism",
+ "installed_version": "0.1.1",
+ "previous_version_preserved": "0.1.0",
+ "manifest_sha256": "91ba35ee3aa1ca226052c94ed682e430cb7870beb241f16b17d889046e1f711d",
+ "engine_sha256": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d",
+ "identical_engine_in_both_hosts": true,
+ "origin_tests": {
+ "assertions": 566,
+ "passed": 86
+ },
+ "host_tests": {
+ "assertions": 11,
+ "runtime": "PHP 8.4.27",
+ "database_used": false,
+ "passed": 2
+ },
+ "update_cycle": [
+ "install 0.1.0",
+ "update 0.1.1",
+ "rollback 0.1.0",
+ "reactivate 0.1.1",
+ "retry without reinstall"
+ ],
+ "tooling_artifacts_verified": 4,
+ "capabilities": [
+ "inventory.replenishment_projection"
+ ],
+ "all_workflows_transferred": false,
+ "runtime_requires_source_checkout": false,
+ "persistent_data_migrated": false,
+ "permissions_changed": false,
+ "dependencies_changed": false,
+ "domain_published": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-access-delivery.json b/docs/proofs/sgf-access-delivery.json
new file mode 100644
index 0000000..cc730cf
--- /dev/null
+++ b/docs/proofs/sgf-access-delivery.json
@@ -0,0 +1,35 @@
+{
+ "passed": true,
+ "checks": [
+ "existing administration lists new catalogue",
+ "unit creation grants only legacy rights",
+ "denied 403",
+ "denied 409",
+ "nominative authorised initialisation choices",
+ "initial authority screen selects explicit rights and includes context and CSRF",
+ "screen has no selected grants and retains operation key on retry",
+ "denied 422",
+ "audit failure rolls back initial grants",
+ "explicit nominated administrator initialisation",
+ "initial authority audited",
+ "unselected rights absent",
+ "initialisation retry idempotent",
+ "initialised screen cannot offer a second initial grant",
+ "normal administrative grant",
+ "normal administrative revocation",
+ "denied 403",
+ "denied 403",
+ "failed cascade preserves unit access",
+ "unit revocation removes derived catalogue rights",
+ "initialisation replay never resurrects revoked rights",
+ "denied 409",
+ "denied 403",
+ "denied 403"
+ ],
+ "installed": false,
+ "overlay": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/ACCESS-CANDIDATE-a69f06e21747228a",
+ "checked_at": "2026-11-01T14:19:42+00:00",
+ "destination_version": "0.1.1",
+ "prior_tests_repeated": false,
+ "isolated_database_removed": true
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-operational-delivery.json b/docs/proofs/sgf-operational-delivery.json
new file mode 100644
index 0000000..42b169d
--- /dev/null
+++ b/docs/proofs/sgf-operational-delivery.json
@@ -0,0 +1,40 @@
+{
+ "passed": true,
+ "checks": [
+ "CLI plan is read-only and identifies destination",
+ "CLI refuses unverified backup before schema write",
+ "Destino, backup ou operador n\u00e3o conferidos.",
+ "Schema or\u00e7amental parcial; n\u00e3o completar \u00e0s cegas.",
+ "Schema or\u00e7amental incompat\u00edvel com o contrato aceite.",
+ "clean budget schema installed without synthetic records",
+ "clean identity catalogue supports budget rights without automatic grants",
+ "Injected operational DDL failure",
+ "failed installation leaves no partial module or receipt",
+ "failed preparation preserves existing budget and rights",
+ "schema prepared without code activation",
+ "CLI records actual verified custom backup hash",
+ "installation retry preserves one receipt",
+ "successful preparation preserves grants and approved budget",
+ "operational composition works outside QA environment",
+ "operational composition reuses existing budget reservation motor",
+ "operational budget checks revoked permission",
+ "unconfigured operational unit refused",
+ "divergent schema refused without rewriting it",
+ "schema preparation history immutable"
+ ],
+ "installed_on_destination": false,
+ "activated": false,
+ "restored_operational_composition": {
+ "verified": true,
+ "production_environment": true,
+ "database_outside_QA_prefix": true,
+ "schema_receipt_and_budget_reservation_preserved": true,
+ "revocation_preserved": true
+ },
+ "recovery_dump_sha256": "f1601584154c17b0b3402ec3efcaa51d5aedb58a56bb919a201193c0af5715b7",
+ "overlay": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5",
+ "checked_at": "2026-11-01T14:27:17+00:00",
+ "destination_version": "0.1.1",
+ "prior_tests_repeated": false,
+ "isolated_database_removed": true
+}
\ No newline at end of file
diff --git a/docs/proofs/supplier-reversals-20261030.json b/docs/proofs/supplier-reversals-20261030.json
new file mode 100644
index 0000000..d7b1dcf
--- /dev/null
+++ b/docs/proofs/supplier-reversals-20261030.json
@@ -0,0 +1,266 @@
+{
+ "version": "COMMON-ALLOCATION-FULL-REVERSAL-SIM-v1",
+ "checked_at": "2026-10-30T21:39:35.946Z",
+ "core_sha256": "4a719ae1456df974877e2555dd6d559f26ebc40b90ae3ff7e11874cfbf2f106b",
+ "hosts": [
+ {
+ "host": "compras",
+ "passed": true,
+ "stages": [
+ {
+ "stage": "before_reversal",
+ "debt_minor": 760000,
+ "unallocated_minor": 0
+ },
+ {
+ "stage": "full_reversal",
+ "debt_minor": 1160000,
+ "unallocated_minor": 400000
+ },
+ {
+ "stage": "corrected_allocation",
+ "debt_minor": 860000,
+ "unallocated_minor": 100000
+ }
+ ],
+ "due_on": null,
+ "days_overdue": null,
+ "originals_preserved": true,
+ "full_reversals": 1,
+ "payments": 1,
+ "allocations": 2,
+ "events": 9,
+ "outbox": 9,
+ "integrity": {
+ "immutable_operations_refused": 4,
+ "invalid_compensations_refused": 3
+ },
+ "races": [
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 403
+ },
+ {
+ "status": 403
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "reversal_id": 2,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "reversal_id": 2,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 3,
+ "decision_id": 2,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 3,
+ "decision_id": 2,
+ "replayed": false
+ }
+ }
+ ]
+ }
+ ],
+ "checks": [
+ "proposal_has_no_effect",
+ "new_key_retry_recovers",
+ "incompatible_repeat_conflicts",
+ "partial_payload_refused",
+ "single_full_compensation",
+ "payment_remains_confirmed",
+ "distinct_actor_and_person",
+ "permission_revocation_after_wait",
+ "cross_company_refused",
+ "source_version_conflict",
+ "rollback_before_event_and_same_key_recovery",
+ "balance_limits",
+ "same_plan_new_cycle_old_reference_recovery",
+ "original_confirmation_cannot_reapply_reversed_effect",
+ "canonical_host_balance",
+ "history_reentry",
+ "read_only_role_without_forms"
+ ],
+ "visual_review": false
+ },
+ {
+ "host": "sgf",
+ "passed": true,
+ "stages": [
+ {
+ "stage": "before_reversal",
+ "debt_minor": 760000,
+ "unallocated_minor": 0
+ },
+ {
+ "stage": "full_reversal",
+ "debt_minor": 1160000,
+ "unallocated_minor": 400000
+ },
+ {
+ "stage": "corrected_allocation",
+ "debt_minor": 860000,
+ "unallocated_minor": 100000
+ }
+ ],
+ "due_on": null,
+ "days_overdue": null,
+ "originals_preserved": true,
+ "full_reversals": 1,
+ "payments": 1,
+ "allocations": 2,
+ "events": 9,
+ "outbox": 9,
+ "integrity": {
+ "immutable_operations_refused": 4,
+ "invalid_compensations_refused": 3
+ },
+ "races": [
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 403
+ },
+ {
+ "status": 403
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "reversal_id": 2,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "reversal_id": 2,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 3,
+ "decision_id": 2,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 3,
+ "decision_id": 2,
+ "replayed": false
+ }
+ }
+ ]
+ }
+ ],
+ "checks": [
+ "proposal_has_no_effect",
+ "new_key_retry_recovers",
+ "incompatible_repeat_conflicts",
+ "partial_payload_refused",
+ "single_full_compensation",
+ "payment_remains_confirmed",
+ "distinct_actor_and_person",
+ "permission_revocation_after_wait",
+ "cross_company_refused",
+ "source_version_conflict",
+ "rollback_before_event_and_same_key_recovery",
+ "balance_limits",
+ "same_plan_new_cycle_old_reference_recovery",
+ "original_confirmation_cannot_reapply_reversed_effect",
+ "canonical_host_balance",
+ "history_reentry",
+ "read_only_role_without_forms"
+ ],
+ "visual_review": false
+ }
+ ],
+ "public_enabled": false,
+ "bank_execution": false,
+ "accounting": false,
+ "persistent_demo_upgraded": false,
+ "visual_review": false
+}
diff --git a/docs/proofs/supplier-reversals-baseline-20261030.json b/docs/proofs/supplier-reversals-baseline-20261030.json
new file mode 100644
index 0000000..febaa9a
--- /dev/null
+++ b/docs/proofs/supplier-reversals-baseline-20261030.json
@@ -0,0 +1,93 @@
+{
+ "checked_at": "2026-10-30T21:38:43.475Z",
+ "core_unchanged": false,
+ "core_sha256": "4a719ae1456df974877e2555dd6d559f26ebc40b90ae3ff7e11874cfbf2f106b",
+ "hosts": [
+ {
+ "host": "compras",
+ "passed": true,
+ "kind": "HTTP pages, form availability, submitted commands and JavaScript syntax; not browser execution",
+ "stages": [
+ {
+ "stage": "recognised",
+ "debt_minor": 1160000
+ },
+ {
+ "stage": "payment_confirmed_before_allocation",
+ "debt_minor": 1160000,
+ "unallocated_minor": 400000
+ },
+ {
+ "stage": "allocation_confirmed",
+ "debt_minor": 760000,
+ "unallocated_minor": 0
+ }
+ ],
+ "principal_minor": 1160000,
+ "due_on": null,
+ "days_overdue": null,
+ "payments": 1,
+ "allocations": 1,
+ "repeat_recovered": true,
+ "payment_confirmation_repeat_recovered": true,
+ "event_counts": {
+ "supplier_payment.proposed": 1,
+ "supplier_payment.confirmed": 1,
+ "supplier_allocation.proposed": 1,
+ "supplier_allocation.confirmed": 1
+ },
+ "distinct_validation_actors": true,
+ "history_after_reentry": true,
+ "canonical_balance_on_both_pages": true,
+ "role_destination_retained": true,
+ "external_redirect_refused": true,
+ "read_only_without_forms": true,
+ "visual_review": false
+ },
+ {
+ "host": "sgf",
+ "passed": true,
+ "kind": "HTTP pages, form availability, submitted commands and JavaScript syntax; not browser execution",
+ "stages": [
+ {
+ "stage": "recognised",
+ "debt_minor": 1160000
+ },
+ {
+ "stage": "payment_confirmed_before_allocation",
+ "debt_minor": 1160000,
+ "unallocated_minor": 400000
+ },
+ {
+ "stage": "allocation_confirmed",
+ "debt_minor": 760000,
+ "unallocated_minor": 0
+ }
+ ],
+ "principal_minor": 1160000,
+ "due_on": null,
+ "days_overdue": null,
+ "payments": 1,
+ "allocations": 1,
+ "repeat_recovered": true,
+ "payment_confirmation_repeat_recovered": true,
+ "event_counts": {
+ "supplier_payment.proposed": 1,
+ "supplier_payment.confirmed": 1,
+ "supplier_allocation.proposed": 1,
+ "supplier_allocation.confirmed": 1
+ },
+ "distinct_validation_actors": true,
+ "history_after_reentry": true,
+ "canonical_balance_on_both_pages": true,
+ "role_destination_retained": true,
+ "external_redirect_refused": true,
+ "read_only_without_forms": true,
+ "visual_review": false
+ }
+ ],
+ "visual_blocker": "Previous Firefox attempt stopped: browser URL policy enforcement is not yet supported for the current Windows browser. Browser not retried in this consolidation.",
+ "public_enabled": false,
+ "bank_execution": false,
+ "accepted_proof_preserved": true
+}
diff --git a/docs/proofs/supplier-reversals-legacy-regression-20261030.json b/docs/proofs/supplier-reversals-legacy-regression-20261030.json
new file mode 100644
index 0000000..4a6e2fb
--- /dev/null
+++ b/docs/proofs/supplier-reversals-legacy-regression-20261030.json
@@ -0,0 +1,299 @@
+{
+ "version": "COMMON-SUPPLIER-SETTLEMENT-SIM-v1",
+ "checked_at": "2026-10-30T21:41:16.326Z",
+ "hosts": [
+ {
+ "host": "compras",
+ "passed": true,
+ "core_sha256": "4a719ae1456df974877e2555dd6d559f26ebc40b90ae3ff7e11874cfbf2f106b",
+ "principal_minor": 1160000,
+ "balance_after_first_allocation_minor": 760000,
+ "due_on": null,
+ "days_overdue": null,
+ "multi_document_core": {
+ "balances": [
+ 0,
+ 0,
+ 100000
+ ],
+ "unallocated_minor": 40000,
+ "foreign_supplier_refused": true,
+ "other_unit_reference_refused": true,
+ "kind": "core explicit synthetic source adapter; not another host invoice workflow"
+ },
+ "races": [
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 403
+ },
+ {
+ "status": 403
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "payment_id": 1,
+ "decision_id": 1,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "payment_id": 1,
+ "decision_id": 1,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 403
+ },
+ {
+ "status": 403
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "decision_id": 2,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "decision_id": 2,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 2,
+ "decision_id": 3,
+ "replayed": false
+ }
+ },
+ {
+ "status": 409,
+ "message": "Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa."
+ }
+ ]
+ }
+ ],
+ "checks": [
+ "payment_confirmation_does_not_reduce_debt",
+ "explicit_allocations_only",
+ "new_request_keys_recover_same_fact",
+ "duplicate_evidence_conflicts",
+ "partial_and_excess",
+ "overallocation_refused",
+ "rejected_payment_or_allocation_has_no_effect",
+ "separate_actor_and_person",
+ "revocation_while_waiting",
+ "atomic_fault_rollback_and_retry",
+ "source_revision_conflict",
+ "cross_company_refused",
+ "cross_supplier_refused",
+ "history_not_ignored",
+ "immutable_confirmed_facts",
+ "legacy_balance_uses_common_position",
+ "unchanged_stock_budget_invoice_legacy_payments",
+ "csrf_and_context",
+ "logout_reentry_history",
+ "durable_outbox"
+ ],
+ "visual_review": false
+ },
+ {
+ "host": "sgf",
+ "passed": true,
+ "core_sha256": "4a719ae1456df974877e2555dd6d559f26ebc40b90ae3ff7e11874cfbf2f106b",
+ "principal_minor": 1160000,
+ "balance_after_first_allocation_minor": 760000,
+ "due_on": null,
+ "days_overdue": null,
+ "multi_document_core": {
+ "balances": [
+ 0,
+ 0,
+ 100000
+ ],
+ "unallocated_minor": 40000,
+ "foreign_supplier_refused": true,
+ "other_unit_reference_refused": true,
+ "kind": "core explicit synthetic source adapter; not another host invoice workflow"
+ },
+ "races": [
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 403
+ },
+ {
+ "status": 403
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "payment_id": 1,
+ "decision_id": 1,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "payment_id": 1,
+ "decision_id": 1,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 403
+ },
+ {
+ "status": 403
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "decision_id": 2,
+ "replayed": false
+ }
+ },
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 1,
+ "decision_id": 2,
+ "replayed": false
+ }
+ }
+ ]
+ },
+ {
+ "waits": 2,
+ "results": [
+ {
+ "status": 200,
+ "result": {
+ "proposal_id": 2,
+ "decision_id": 3,
+ "replayed": false
+ }
+ },
+ {
+ "status": 409,
+ "message": "Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa."
+ }
+ ]
+ }
+ ],
+ "checks": [
+ "payment_confirmation_does_not_reduce_debt",
+ "explicit_allocations_only",
+ "new_request_keys_recover_same_fact",
+ "duplicate_evidence_conflicts",
+ "partial_and_excess",
+ "overallocation_refused",
+ "rejected_payment_or_allocation_has_no_effect",
+ "separate_actor_and_person",
+ "revocation_while_waiting",
+ "atomic_fault_rollback_and_retry",
+ "source_revision_conflict",
+ "cross_company_refused",
+ "cross_supplier_refused",
+ "history_not_ignored",
+ "immutable_confirmed_facts",
+ "legacy_balance_uses_common_position",
+ "unchanged_stock_budget_invoice_legacy_payments",
+ "csrf_and_context",
+ "logout_reentry_history",
+ "durable_outbox"
+ ],
+ "visual_review": false
+ }
+ ],
+ "bank_execution": false,
+ "accounting": false,
+ "real_migration": false
+}
diff --git a/docs/proofs/supplier-reversals-verification-20261030.json b/docs/proofs/supplier-reversals-verification-20261030.json
new file mode 100644
index 0000000..858143c
--- /dev/null
+++ b/docs/proofs/supplier-reversals-verification-20261030.json
@@ -0,0 +1,27 @@
+{
+ "checked_at": "2026-10-30T21:41:32.834205+00:00",
+ "version": "COMMON-ALLOCATION-FULL-REVERSAL-SIM-v1",
+ "core_sha256": "4a719ae1456df974877e2555dd6d559f26ebc40b90ae3ff7e11874cfbf2f106b",
+ "both_hosts_passed": true,
+ "old_schema_baseline_passed": true,
+ "upgraded_schema_adversarial_regression_passed": true,
+ "postgres_reversal_races": 8,
+ "host_financial_regression_races": 12,
+ "affected_compras_tests": 81,
+ "assertions": 343,
+ "phpstan_errors": 0,
+ "pint": "passed",
+ "builds": {
+ "sgf": "passed",
+ "compras": "passed"
+ },
+ "home_navigation": "passed",
+ "accepted_proofs_preserved": true,
+ "disposable_processes_databases_roles_cleaned": true,
+ "persistent_demo_upgrade_applied": false,
+ "visual_review": false,
+ "mfa_coverage": "previous two skips remain pending",
+ "public_enabled": false,
+ "published": false,
+ "checkpoint_created": false
+}
diff --git a/package.json b/package.json
index e7e4687..3a26934 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
"check:navigation": "node scripts/home-navigation-check.mjs",
- "build": "node scripts/home-navigation-check.mjs && vite build"
+ "check:modules": "node scripts/module-navigation-check.mjs",
+ "build": "node scripts/home-navigation-check.mjs && node scripts/module-navigation-check.mjs && vite build"
},
"dependencies": {
"react": "19.2.9",
diff --git a/packages/module-updates/README.md b/packages/module-updates/README.md
new file mode 100644
index 0000000..77c1345
--- /dev/null
+++ b/packages/module-updates/README.md
@@ -0,0 +1,11 @@
+# Distribuição Compras → SGF
+
+Fonte canónica: este directório em Compras. As cópias no SGF são artefactos do mesmo mecanismo, não implementações mantidas em paralelo. O runtime instalado nunca procura a pasta de Compras. PHP ZipArchive e as dependências já instaladas de cada host são reutilizados.
+
+O formato 1 transporta apenas código, metadados e documentação do pacote Purchasing. Exclui `.env`, bases, documentos, contas, dados de teste e concessões. Exige uma referência SHA-256 de manifesto obtida de um canal de confiança; hashes não substituem a autenticidade desse canal. Não existe download de URLs arbitrários, execução de scripts de migração ou substituição de dependências. Distribuição remota assinada e migrações de módulos com dados exigem extensão explícita do contrato.
+
+O editor publica uma versão patch apenas quando os bytes da origem mudam. A publicação oficial executa regressões e uma prova da fotografia imutável antes de actualizar o catálogo. A instalação verifica o pacote inteiro, prova a candidata num processo PHP do host, verifica a integridade da versão anterior e activa uma pasta imutável por ponteiro atómico. Bloqueio de ficheiro serializa instalações concorrentes. Código alterado localmente, versão repetida com outros bytes, downgrade implícito, ficheiros extra/ausentes e caminhos fora do pacote são recusados. A versão anterior não é apagada.
+
+Histórico de activações fica na instalação, separado do código distribuído. Uma falha antes da troca do ponteiro mantém a versão activa; preparações incompletas podem permanecer para diagnóstico. `rollback` regressa explicitamente à versão de código anterior verificada, sem apagar ou recalcular dados. O contrato actual não permite mudanças de schema, tornando essa reversão independente da base.
+
+Actualizar este próprio mecanismo exige distribuir e conferir os seus artefactos de arranque, em separado do pacote de negócio. Não sobrescrever modificações locais por conveniência. Serviços de longa duração devem ser reiniciados pelo procedimento de operação da instalação depois de uma actualização; não são terminados automaticamente pelo instalador.
diff --git a/packages/module-updates/Release.php b/packages/module-updates/Release.php
new file mode 100644
index 0000000..c01ddb2
--- /dev/null
+++ b/packages/module-updates/Release.php
@@ -0,0 +1,295 @@
+isLink(), 'Links não são permitidos no pacote.');
+ if (! $file->isFile()) {
+ continue;
+ }
+ $path = str_replace('\\', '/', substr($file->getPathname(), strlen($source) + 1));
+ self::fail(self::path($path), 'Ficheiro fora do contrato do pacote: '.$path);
+ $size += $file->getSize();
+ self::fail($size <= self::LIMIT, 'Pacote demasiado grande.');
+ $contents[$path] = file_get_contents($file->getPathname());
+ $files[$path] = hash('sha256', $contents[$path]);
+ }
+ ksort($files);
+ self::fail(isset($files['bootstrap.php'], $files['module.json']) && count($files) <= 20000, 'Pacote incompleto.');
+ $digest = hash('sha256', self::encode($files));
+ self::fail(is_dir($channel) || mkdir($channel, 0700, true), 'Canal de distribuição indisponível.');
+ $lock = fopen($channel.'/.publish.lock', 'c');
+ self::fail($lock !== false && flock($lock, LOCK_EX), 'Publicação ocupada.');
+ try {
+ $catalog = is_file($channel.'/catalog.json') ? self::json($channel.'/catalog.json') : ['schema' => 1, 'releases' => []];
+ self::fail(($catalog['schema'] ?? null) === 1 && is_array($catalog['releases'] ?? null), 'Canal incompatível.');
+ $last = $catalog['releases'] === [] ? null : $catalog['releases'][array_key_last($catalog['releases'])];
+ if ($last !== null && ($last['source_sha256'] ?? null) === $digest) {
+ self::inspect($channel.'/'.$last['archive'], $last['manifest_sha256']);
+
+ return $last + ['changed' => false];
+ }
+ $version = $last === null ? '0.1.0' : implode('.', [...array_slice(explode('.', $last['version']), 0, 2), ((int) explode('.', $last['version'])[2]) + 1]);
+ self::version($version);
+ $manifest = ['schema' => 1, 'package' => 'tempo/purchasing', 'version' => $version,
+ 'source_sha256' => $digest, 'module' => $module, 'files' => $files];
+ $manifestText = self::encode($manifest);
+ $filename = 'purchasing-'.$version.'.zip';
+ self::fail(! file_exists($channel.'/'.$filename), 'Versão já existe; não será sobrescrita.');
+ $temporary = $channel.'/.publish-'.bin2hex(random_bytes(8)).'.zip';
+ $zip = new ZipArchive;
+ self::fail($zip->open($temporary, ZipArchive::CREATE | ZipArchive::EXCL) === true, 'Não foi possível criar o pacote.');
+ foreach ($files as $path => $hash) {
+ self::fail($zip->addFromString('payload/'.$path, $contents[$path]), 'Falha ao empacotar código.');
+ }
+ $zip->addFromString('manifest.json', $manifestText);
+ self::fail($zip->close(), 'Falha ao concluir o pacote.');
+ $entry = ['version' => $version, 'archive' => $filename, 'source_sha256' => $digest, 'manifest_sha256' => hash('sha256', $manifestText)];
+ self::inspect($temporary, $entry['manifest_sha256']);
+ self::fail(rename($temporary, $channel.'/'.$filename), 'Falha ao publicar versão.');
+ $catalog['releases'][] = $entry;
+ self::write($channel.'/catalog.next.json', self::encode($catalog));
+ self::fail(rename($channel.'/catalog.next.json', $channel.'/catalog.json'), 'Falha ao publicar catálogo.');
+
+ return $entry + ['changed' => true];
+ } finally {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ }
+ }
+
+ /** Validate every archive byte and reject undeclared files before writing anywhere. */
+ public static function inspect(string $archive, string $expectedManifest): array
+ {
+ self::fail(preg_match('/^[a-f0-9]{64}$/D', $expectedManifest) === 1 && is_file($archive) && filesize($archive) <= self::LIMIT, 'Pacote ou referência de confiança inválidos.');
+ $zip = new ZipArchive;
+ self::fail($zip->open($archive, ZipArchive::RDONLY) === true, 'Pacote ilegível.');
+ try {
+ $metadata = $zip->statName('manifest.json');
+ self::fail(is_array($metadata) && $metadata['size'] < 1048576 && $zip->numFiles < 20001, 'Manifesto ou arquivo demasiado grande.');
+ $raw = $zip->getFromName('manifest.json');
+ self::fail(is_string($raw) && strlen($raw) < 1048576 && hash_equals($expectedManifest, hash('sha256', $raw)), 'Manifesto diferente da referência aprovada.');
+ $manifest = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
+ self::fail(($manifest['schema'] ?? null) === 1 && ($manifest['package'] ?? '') === 'tempo/purchasing'
+ && ($manifest['module']['host_contract'] ?? null) === 1 && ($manifest['module']['database_changes'] ?? null) === false
+ && ($manifest['module']['commercial_permissions'] ?? null) === false, 'Contrato do pacote incompatível.');
+ self::version($manifest['version']);
+ self::fail(version_compare(PHP_VERSION, $manifest['module']['minimum_php'], '>='), 'Versão PHP incompatível.');
+ $files = $manifest['files'] ?? [];
+ self::fail(is_array($files) && isset($files['bootstrap.php'], $files['module.json']) && count($files) < 20000, 'Pacote incompleto.');
+ $seen = [];
+ $contents = [];
+ $size = 0;
+ for ($index = 0; $index < $zip->numFiles; $index++) {
+ $stat = $zip->statIndex($index);
+ $name = $stat['name'];
+ self::fail(! isset($seen[$name]), 'Entrada duplicada no pacote.');
+ $seen[$name] = true;
+ $size += $stat['size'];
+ self::fail($size <= self::LIMIT, 'Pacote expandido demasiado grande.');
+ if ($name === 'manifest.json') {
+ continue;
+ }
+ self::fail(str_starts_with($name, 'payload/'), 'Entrada inesperada no pacote.');
+ $path = substr($name, 8);
+ self::fail(self::path($path) && isset($files[$path]), 'Caminho fora do pacote.');
+ $value = $zip->getFromIndex($index);
+ self::fail(is_string($value) && hash_equals($files[$path], hash('sha256', $value)), 'Código divergente do manifesto.');
+ $contents[$path] = $value;
+ }
+ self::fail(count($contents) === count($files), 'Faltam ficheiros no pacote.');
+ $sorted = $files;
+ ksort($sorted);
+ self::fail(hash_equals($manifest['source_sha256'], hash('sha256', self::encode($sorted))), 'Identidade da origem inválida.');
+ self::fail(json_decode($contents['module.json'], true, 32, JSON_THROW_ON_ERROR) === $manifest['module'], 'Metadados do módulo divergentes.');
+
+ return ['manifest' => $manifest, 'manifest_text' => $raw, 'contents' => $contents];
+ } finally {
+ $zip->close();
+ }
+ }
+
+ /** Read the installed pointer and verify its immutable source files. */
+ public static function current(string $host): ?array
+ {
+ $base = rtrim($host, '/\\').'/packages/purchasing';
+ if (! is_file($base.'/current.json')) {
+ return null;
+ }
+
+ return self::verifiedPointer($host, self::json($base.'/current.json'));
+ }
+
+ private static function verifiedPointer(string $host, array $pointer): array
+ {
+ $base = rtrim($host, '/\\').'/packages/purchasing';
+ self::version($pointer['version']);
+ $release = $base.'/releases/'.$pointer['version'];
+ $realHost = str_replace('\\', '/', realpath($host) ?: '');
+ $realRelease = str_replace('\\', '/', realpath($release) ?: '');
+ self::fail($realHost !== '' && str_starts_with(strtolower($realRelease), strtolower($realHost).'/packages/purchasing/releases/'), 'Caminho de versão fora da instalação.');
+ $manifest = self::json($release.'/manifest.json');
+ self::fail(($manifest['version'] ?? '') === $pointer['version'] && ($manifest['package'] ?? '') === 'tempo/purchasing', 'Identidade da versão instalada inválida.');
+ self::fail(hash_equals($pointer['manifest_sha256'], hash_file('sha256', $release.'/manifest.json')), 'Versão instalada diverge da referência.');
+ foreach ($manifest['files'] as $path => $hash) {
+ $realFile = str_replace('\\', '/', realpath($release.'/'.$path) ?: '');
+ self::fail(self::path($path) && ! is_link($release.'/'.$path) && is_file($release.'/'.$path)
+ && str_starts_with(strtolower($realFile), strtolower($realRelease).'/')
+ && hash_equals($hash, hash_file('sha256', $release.'/'.$path)), 'Versão instalada foi alterada: '.$path);
+ }
+ foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($release, \FilesystemIterator::SKIP_DOTS)) as $file) {
+ self::fail(! $file->isLink(), 'Link inesperado na versão instalada.');
+ if ($file->isFile()) {
+ $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($release) + 1));
+ self::fail($relative === 'manifest.json' || isset($manifest['files'][$relative]), 'Ficheiro não declarado na versão instalada.');
+ }
+ }
+
+ return $pointer + ['path' => $release, 'capabilities' => $manifest['module']['capabilities']];
+ }
+
+ private static function activate(string $base, ?array $before, array $pointer, string $action): void
+ {
+ $pointer['audit_id'] = bin2hex(random_bytes(12));
+ self::fail(! is_link($base.'/history') && (is_dir($base.'/history') || mkdir($base.'/history', 0700)), 'Histórico indisponível.');
+ self::write($base.'/history/'.$pointer['audit_id'].'.json', self::encode(['action' => $action, 'at' => gmdate('c'), 'before' => $before, 'after' => $pointer, 'database_changed' => false]));
+ self::write($base.'/current.next.json', self::encode($pointer));
+ self::fail(rename($base.'/current.next.json', $base.'/current.json'), 'Falha ao activar a versão; versão anterior preservada.');
+ }
+
+ /** Explicit rollback only to the previous verified code release; never rolls back data. */
+ public static function rollback(string $host): array
+ {
+ $base = rtrim($host, '/\\').'/packages/purchasing';
+ self::fail(is_dir($base) && ! is_link($base) && ! is_link(rtrim($host, '/\\').'/packages'), 'Módulo não instalado ou caminho inválido.');
+ $lock = fopen($base.'/.update.lock', 'c');
+ self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.');
+ try {
+ $current = self::current($host);
+ self::fail($current !== null && preg_match('/^[a-f0-9]{24}$/D', $current['audit_id']) === 1, 'Sem versão anterior.');
+ $history = self::json($base.'/history/'.$current['audit_id'].'.json');
+ $previous = $history['before'] ?? null;
+ self::fail(is_array($previous), 'Sem versão anterior.');
+ $verified = self::verifiedPointer($host, $previous);
+ $pointer = array_intersect_key($verified, array_flip(['version', 'manifest_sha256', 'source_sha256']));
+ $pointer['previous'] = $current['version'];
+ self::activate($base, $current, $pointer, 'rollback_code_release');
+
+ return self::current($host);
+ } finally {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ }
+ }
+
+ /** Immutable version installation + atomic pointer. No migrations, seeds or permissions. */
+ public static function install(string $host, string $archive, string $expectedManifest): array
+ {
+ $checked = self::inspect($archive, $expectedManifest);
+ self::fail(is_dir($host) && ! is_link($host), 'Instalação de destino inexistente.');
+ $host = realpath($host);
+ self::fail((self::json($host.'/composer.json')['name'] ?? '') === 'tempo/sgf-tempo', 'Destino não é uma instalação SGF.');
+ self::fail(! is_link($host.'/packages'), 'O directório de pacotes não pode redireccionar a instalação.');
+ $base = $host.'/packages/purchasing';
+ self::fail(! is_link($base) && (is_dir($base) || mkdir($base, 0700, true)), 'Directório de módulo inválido.');
+ $lock = fopen($base.'/.update.lock', 'c');
+ self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.');
+ try {
+ $current = self::current($host);
+ $version = $checked['manifest']['version'];
+ if ($current !== null && $current['version'] === $version) {
+ self::fail(hash_equals($current['manifest_sha256'], $expectedManifest), 'Tentativa de substituir uma versão imutável.');
+
+ return $current + ['changed' => false];
+ }
+ self::fail($current === null || version_compare($version, $current['version'], '>'), 'Actualização não permite downgrade implícito.');
+ $releases = $base.'/releases';
+ self::fail(! is_link($releases) && (is_dir($releases) || mkdir($releases, 0700)), 'Directório de versões inválido.');
+ $destination = $releases.'/'.$version;
+ if (is_dir($destination)) {
+ $pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null];
+ self::verifiedPointer($host, $pointer);
+ self::activate($base, $current, $pointer, 'reactivate_verified_code_release');
+
+ return self::current($host) + ['changed' => true];
+ }
+ self::fail(! file_exists($destination), 'Destino de versão inválido.');
+ $stage = $releases.'/.stage-'.bin2hex(random_bytes(8));
+ self::fail(mkdir($stage, 0700), 'Preparação da versão falhou.');
+ foreach ($checked['contents'] as $path => $content) {
+ $directory = dirname($stage.'/'.$path);
+ self::fail(is_dir($directory) || mkdir($directory, 0700, true), 'Preparação do código falhou.');
+ self::write($stage.'/'.$path, $content);
+ }
+ self::write($stage.'/manifest.json', $checked['manifest_text']);
+ self::fail(rename($stage, $destination), 'Falha ao fixar a versão preparada.');
+ $pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null];
+ self::verifiedPointer($host, $pointer);
+ self::activate($base, $current, $pointer, 'activate_code_release');
+
+ return self::current($host) + ['changed' => true];
+ } finally {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ }
+ }
+}
diff --git a/packages/module-updates/purchasing-bootstrap.php b/packages/module-updates/purchasing-bootstrap.php
new file mode 100644
index 0000000..4f6a14a
--- /dev/null
+++ b/packages/module-updates/purchasing-bootstrap.php
@@ -0,0 +1,12 @@
+environment('payable-testing') || $db->getDriverName() !== 'pgsql' || $db->getConfig('host') !== '127.0.0.1'
+ || ! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName())
+ || $db->selectOne('select current_database() as d')->d !== $db->getDatabaseName()) {
+ throw new \LogicException('Reversal upgrade requires isolated QA.');
+ }
+ foreach (SettlementSchema::TABLES as $table) {
+ if (! Schema::hasTable($table)) {
+ throw new \LogicException('Complete settlement schema required.');
+ }
+ }
+ if (array_filter(self::TABLES, fn ($t) => Schema::hasTable($t)) || Schema::hasColumn('ps_proposals', 'allocation_cycle')) {
+ throw new \LogicException('Upgrade already present or incomplete; no automatic repair.');
+ }
+ DB::transaction(function (): void {
+ Schema::create('ps_reversal_proposals', function (Blueprint $t): void {
+ $t->id();
+ foreach (['tenant_id', 'company_id', 'unit_id'] as $field) {
+ $t->unsignedBigInteger($field);
+ }
+ $t->foreignId('allocation_id')->unique()->constrained('ps_allocations');
+ $t->string('source_revision', 64);
+ $t->unsignedBigInteger('prepared_by');
+ $t->string('prepared_person');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ });
+ Schema::create('ps_reversals', function (Blueprint $t): void {
+ $t->id();
+ foreach (['tenant_id', 'company_id', 'unit_id'] as $field) {
+ $t->unsignedBigInteger($field);
+ }
+ $t->foreignId('allocation_id')->unique()->constrained('ps_allocations');
+ $t->foreignId('proposal_id')->unique()->constrained('ps_reversal_proposals');
+ $t->foreignId('payment_id')->constrained('ps_payments');
+ $t->foreignId('obligation_id')->constrained('pa_obligations');
+ $t->bigInteger('amount_minor');
+ $t->string('source_revision', 64);
+ $t->unsignedBigInteger('actor_id');
+ $t->string('person');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ });
+ foreach (self::TABLES as $table) {
+ DB::unprepared("CREATE TRIGGER ps_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
+ }
+ // DB protection of the entire compensation, including context and distinct identities.
+ DB::unprepared(<<<'SQL'
+CREATE FUNCTION ps_reversal_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM ps_allocations a JOIN ps_reversal_proposals p ON p.allocation_id=a.id
+ WHERE a.id=NEW.allocation_id AND p.id=NEW.proposal_id
+ AND (a.tenant_id,a.company_id,a.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id)
+ AND (p.tenant_id,p.company_id,p.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id)
+ AND a.payment_id=NEW.payment_id AND a.obligation_id=NEW.obligation_id
+ AND a.amount_minor=NEW.amount_minor AND NEW.amount_minor>0
+ AND a.source_revision=NEW.source_revision AND p.source_revision=NEW.source_revision
+ AND p.prepared_by<>NEW.actor_id AND p.prepared_person<>NEW.person
+ ) THEN RAISE EXCEPTION 'Invalid full allocation compensation'; END IF;
+ RETURN NEW;
+END $$;
+CREATE TRIGGER ps_reversal_integrity BEFORE INSERT ON ps_reversals FOR EACH ROW EXECUTE FUNCTION ps_reversal_integrity();
+ALTER TABLE ps_proposals ADD COLUMN allocation_cycle bigint NOT NULL DEFAULT 0;
+ALTER TABLE ps_proposals DROP CONSTRAINT ps_proposals_payment_id_plan_hash_unique;
+ALTER TABLE ps_proposals ADD CONSTRAINT ps_proposals_payment_plan_cycle_unique UNIQUE (payment_id,plan_hash,allocation_cycle);
+ALTER TABLE ps_allocations DROP CONSTRAINT ps_allocations_payment_id_obligation_id_unique;
+ALTER TABLE ps_allocations ADD CONSTRAINT ps_allocations_decision_obligation_unique UNIQUE (decision_id,obligation_id);
+SQL);
+ });
+ }
+}
diff --git a/packages/payables/src/SettlementHttp.php b/packages/payables/src/SettlementHttp.php
index 27709ac..deebff4 100644
--- a/packages/payables/src/SettlementHttp.php
+++ b/packages/payables/src/SettlementHttp.php
@@ -2,6 +2,7 @@
namespace Tempo\Finance\Payables;
+use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -35,6 +36,9 @@ abstract class SettlementHttp extends Controller
return DB::transaction(function () use ($r): array {
$s = app(static::SERVICE);
$user = $r->user();
+ if (! $user instanceof User) {
+ abort(401);
+ }
$settlements = $s->engine($user)->settlements()->listing();
$base = $s->listing($user);
foreach ($base['obligations'] as &$o) {
@@ -43,12 +47,13 @@ abstract class SettlementHttp extends Controller
}
unset($o);
$permissions = [];
- foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'] as $action) {
+ foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm', 'reversal_prepare', 'reversal_confirm'] as $action) {
$permissions[$action] = $s->can($user, $action);
}
return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'],
'allocation_proposals' => $settlements['proposals'], 'allocations' => $settlements['allocations'], 'events' => $settlements['events'],
+ 'reversals_available' => $settlements['reversals_available'], 'reversal_proposals' => $settlements['reversal_proposals'], 'reversals' => $settlements['reversals'],
'permissions' => $permissions, 'current_actor' => $user->id];
});
}
@@ -61,7 +66,11 @@ abstract class SettlementHttp extends Controller
public function page(Request $r): Response
{
$data = $this->data($r);
- $settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $r->user()->name,
+ $user = $r->user();
+ if (! $user instanceof User) {
+ abort(401);
+ }
+ $settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $user->name,
'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token'), 'api' => '/api/supplier-payments',
'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables'];
diff --git a/packages/payables/src/SupplierSettlements.php b/packages/payables/src/SupplierSettlements.php
index afa53d4..165153b 100644
--- a/packages/payables/src/SupplierSettlements.php
+++ b/packages/payables/src/SupplierSettlements.php
@@ -135,12 +135,33 @@ final class SupplierSettlements
return 0;
}
- return (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor');
+ $allocated = (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor');
+ $reversed = $db->getSchemaBuilder()->hasTable('ps_reversals')
+ ? (int) $db->table('ps_reversals')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor') : 0;
+
+ return $allocated - $reversed;
}
private function unallocated(\stdClass $payment): int
{
- return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor');
+ return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor')
+ + ($this->reversalsAvailable() ? (int) $this->q('reversals')->where('payment_id', $payment->id)->sum('amount_minor') : 0);
+ }
+
+ private function reversalsAvailable(): bool
+ {
+ if (! method_exists($this->db, 'getSchemaBuilder')) {
+ throw new \LogicException('A ligação exige consulta explícita do esquema financeiro.');
+ }
+
+ return $this->db->getSchemaBuilder()->hasTable('ps_reversals')
+ && $this->db->getSchemaBuilder()->hasTable('ps_reversal_proposals')
+ && $this->db->getSchemaBuilder()->hasColumn('ps_proposals', 'allocation_cycle');
+ }
+
+ private function cycle(int $payment): int
+ {
+ return $this->reversalsAvailable() ? (int) $this->q('reversals')->where('payment_id', $payment)->max('id') : 0;
}
/** @param array $data
@@ -148,7 +169,8 @@ final class SupplierSettlements
*/
public function command(string $action, array $data, string $key): array
{
- $permissions = ['payment-prepare' => 'payment_prepare', 'payment-decide' => 'payment_confirm', 'allocation-prepare' => 'allocation_prepare', 'allocation-decide' => 'allocation_confirm'];
+ $permissions = ['payment-prepare' => 'payment_prepare', 'payment-decide' => 'payment_confirm', 'allocation-prepare' => 'allocation_prepare', 'allocation-decide' => 'allocation_confirm',
+ 'reversal-prepare' => 'reversal_prepare', 'reversal-confirm' => 'reversal_confirm'];
if (! isset($permissions[$action]) || ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $key)) {
throw new \InvalidArgumentException('Acção ou chave inválida.');
}
@@ -164,6 +186,7 @@ final class SupplierSettlements
$result = match ($action) {
'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data),
'allocation-prepare' => $this->prepareAllocation($data), 'allocation-decide' => $this->decideAllocation($data),
+ 'reversal-prepare' => $this->prepareReversal($data), 'reversal-confirm' => $this->confirmReversal($data),
};
if ($old && Payables::canonical(json_decode($old->result, true, 32, JSON_THROW_ON_ERROR)) !== Payables::canonical($result)) {
throw new \DomainException('Resultado anterior incompatível; não repetir o efeito.');
@@ -283,7 +306,14 @@ final class SupplierSettlements
$this->referenceScope('allocation_aliases', 'reference_key', $this->key($reference));
// Semantic recovery precedes balance checks: an already-applied plan remains recoverable.
$alias = $this->q('allocation_aliases')->where('reference_key', $this->key($reference))->value('proposal_id');
- $old = $this->q('proposals')->where(fn ($q) => $q->where('reference_key', $this->key($reference))->orWhere('id', $alias ?? 0)->orWhere(fn ($q) => $q->where('payment_id', $p->id)->where('plan_hash', $hash)))->get();
+ $old = $this->q('proposals')->where(fn ($q) => $q->where('reference_key', $this->key($reference))->orWhere('id', $alias ?? 0))->get();
+ if ($old->isEmpty()) {
+ $semantic = $this->q('proposals')->where('payment_id', $p->id)->where('plan_hash', $hash);
+ if ($this->reversalsAvailable()) {
+ $semantic->where('allocation_cycle', $this->cycle((int) $p->id));
+ }
+ $old = $semantic->get();
+ }
if ($old->isNotEmpty()) {
if ($old->count() !== 1 || (int) $old->first()->payment_id !== (int) $p->id || $old->first()->plan_hash !== $hash) {
throw new \DomainException('Referência de afectação incompatível.');
@@ -297,7 +327,8 @@ final class SupplierSettlements
$this->checkPlan($p, $plan, true);
$id = $this->db->table('ps_proposals')->insertGetId($this->scope + ['payment_id' => $p->id, 'reference_key' => $this->key($reference),
'reference' => $reference, 'plan_hash' => $hash, 'plan' => Payables::canonical($plan), 'prepared_by' => $this->actor,
- 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
+ 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]
+ + ($this->reversalsAvailable() ? ['allocation_cycle' => $this->cycle((int) $p->id)] : []));
$this->event((int) $p->id, 'supplier_allocation.proposed', ['proposal_id' => $id, 'plan_hash' => $hash]);
return ['proposal_id' => $id];
@@ -316,7 +347,11 @@ final class SupplierSettlements
throw new \DomainException('Montante fora dos limites.');
}
$total += $line['amount_minor'];
- if ($checkBalances && ($line['amount_minor'] > $o['balance_minor'] || $this->q('allocations')->where(['payment_id' => $p->id, 'obligation_id' => $o['id']])->exists())) {
+ $active = $this->q('allocations')->where(['payment_id' => $p->id, 'obligation_id' => $o['id']]);
+ if ($this->reversalsAvailable()) {
+ $active->whereNotIn('id', $this->q('reversals')->select('allocation_id'));
+ }
+ if ($checkBalances && ($line['amount_minor'] > $o['balance_minor'] || $active->exists())) {
throw new \DomainException('Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa.');
}
}
@@ -343,6 +378,9 @@ final class SupplierSettlements
$reason = $this->reason($d['reason']);
$plan = json_decode($proposal->plan, true, 64, JSON_THROW_ON_ERROR);
$old = $this->q('allocation_decisions')->where('proposal_id', $proposal->id)->first();
+ if (! $old && $d['decision'] === 'confirmed' && $this->reversalsAvailable() && (int) $proposal->allocation_cycle !== $this->cycle((int) $p->id)) {
+ throw new \DomainException('Ciclo de afectação desactualizado após reversão; prepare uma nova proposta.');
+ }
$this->checkPlan($p, $plan, ! $old && $d['decision'] === 'confirmed');
if ($old) {
if ($old->decision !== $d['decision'] || $old->reason !== $reason) {
@@ -363,6 +401,75 @@ final class SupplierSettlements
return ['proposal_id' => (int) $proposal->id, 'decision_id' => $id];
}
+ private function reversible(int $id, mixed $revision): \stdClass
+ {
+ if (! $this->reversalsAvailable()) {
+ throw new \DomainException('Reversões não instaladas neste ensaio.');
+ }
+ $a = $this->q('allocations')->where('id', $id)->first() ?? throw new \DomainException('Afectação fora do contexto.');
+ $p = $this->payment((int) $a->payment_id);
+ $o = ($this->obligation)((int) $a->obligation_id);
+ if ($revision !== $a->source_revision || $revision !== $o['source_revision'] || $o['currency'] !== $p->currency || $o['supplier_key'] !== $p->supplier_key) {
+ throw new \DomainException('Versão ou origem incompatível; reversão pendente de revisão.');
+ }
+
+ return $a;
+ }
+
+ /** @param array $d
+ * @return array
+ */
+ private function prepareReversal(array $d): array
+ {
+ $this->exact($d, ['allocation_id', 'source_revision', 'reason']);
+ $a = $this->reversible($this->id($d['allocation_id']), $d['source_revision']);
+ $reason = $this->reason($d['reason']);
+ $old = $this->q('reversal_proposals')->where('allocation_id', $a->id)->first();
+ if ($old) {
+ if ($old->reason !== $reason || $old->source_revision !== $d['source_revision']) {
+ throw new \DomainException('Reversão já proposta com conteúdo diferente.');
+ }
+
+ return ['proposal_id' => (int) $old->id];
+ }
+ $id = $this->db->table('ps_reversal_proposals')->insertGetId($this->scope + ['allocation_id' => $a->id,
+ 'source_revision' => $a->source_revision, 'prepared_by' => $this->actor, 'prepared_person' => $this->person,
+ 'reason' => $reason, 'created_at' => $this->stamp()]);
+ $this->event((int) $a->payment_id, 'supplier_allocation.reversal_proposed', ['proposal_id' => $id, 'allocation_id' => $a->id]);
+
+ return ['proposal_id' => $id];
+ }
+
+ /** @param array $d
+ * @return array
+ */
+ private function confirmReversal(array $d): array
+ {
+ $this->exact($d, ['proposal_id', 'reason']);
+ if (! $this->reversalsAvailable()) {
+ throw new \DomainException('Reversões não instaladas neste ensaio.');
+ }
+ $proposal = $this->q('reversal_proposals')->where('id', $this->id($d['proposal_id']))->first() ?? throw new \DomainException('Proposta de reversão fora do contexto.');
+ $a = $this->reversible((int) $proposal->allocation_id, $proposal->source_revision);
+ $this->independent($proposal);
+ $reason = $this->reason($d['reason']);
+ $old = $this->q('reversals')->where('allocation_id', $a->id)->first();
+ if ($old) {
+ if ((int) $old->proposal_id !== (int) $proposal->id || $old->reason !== $reason) {
+ throw new \DomainException('Afectação já revertida; conteúdo incompatível.');
+ }
+
+ return ['proposal_id' => (int) $proposal->id, 'reversal_id' => (int) $old->id];
+ }
+ $id = $this->db->table('ps_reversals')->insertGetId($this->scope + ['allocation_id' => $a->id, 'proposal_id' => $proposal->id,
+ 'payment_id' => $a->payment_id, 'obligation_id' => $a->obligation_id, 'amount_minor' => $a->amount_minor,
+ 'source_revision' => $a->source_revision, 'actor_id' => $this->actor, 'person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
+ $this->event((int) $a->payment_id, 'supplier_allocation.reversed', ['proposal_id' => $proposal->id, 'reversal_id' => $id,
+ 'allocation_id' => $a->id, 'amount_minor' => (int) $a->amount_minor]);
+
+ return ['proposal_id' => (int) $proposal->id, 'reversal_id' => $id];
+ }
+
/** @return array */
public function listing(): array
{
@@ -377,6 +484,9 @@ final class SupplierSettlements
$proposals = $this->q('proposals')->orderBy('id')->get()->map(fn ($p) => (array) $p + ['decision' => $this->q('allocation_decisions')->where('proposal_id', $p->id)->first()])->all();
return ['synthetic_only' => true, 'payments' => $payments, 'proposals' => $proposals, 'allocations' => $this->q('allocations')->orderBy('id')->get()->all(),
+ 'reversals_available' => $this->reversalsAvailable(),
+ 'reversal_proposals' => $this->reversalsAvailable() ? $this->q('reversal_proposals')->orderBy('id')->get()->all() : [],
+ 'reversals' => $this->reversalsAvailable() ? $this->q('reversals')->orderBy('id')->get()->all() : [],
'events' => $this->q('events')->orderBy('id')->get()->all(), 'current_actor' => $this->actor];
});
}
diff --git a/packages/payables/ui/settlements.php b/packages/payables/ui/settlements.php
index 0c93963..f6b844a 100644
--- a/packages/payables/ui/settlements.php
+++ b/packages/payables/ui/settlements.php
@@ -19,14 +19,24 @@ body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-widt
3. Propor afectações
4. Propostas de afectação
A proposta não reduz a dívida. Troque para Validar para confirmar a afectação com outra identidade.
Estado: = $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?>
+
5. Reversão integral e nova afectação
Propor não altera saldos. Confirmar com outra identidade compensa integralmente a afectação original e volta a disponibilizar esse montante do pagamento. O pagamento continua confirmado: não há cancelamento bancário. Depois, use «Propor afectações» com uma nova referência para corrigir o valor.
+
Actualização de reversões ainda não aplicada neste ensaio. Os saldos e o percurso anterior permanecem disponíveis.
Correcções, reversões, reconciliação e novos pagamentos sobre documentos com histórico importado ficam para âmbito próprio. Os originais e as decisões confirmadas não são editados ou apagados.
+
Reversões parciais, cancelamento bancário, reconciliação e novos pagamentos sobre documentos com histórico importado ficam fora desta entrega. Os originais e as decisões confirmadas não são editados ou apagados.