This commit is contained in:
2026-11-02 08:06:45 +02:00
parent e4f520092e
commit 2a4169162b
108 changed files with 3793 additions and 88 deletions
+3
View File
@@ -33,3 +33,6 @@ Thumbs.db
/storage/framework/budget-demo/ /storage/framework/budget-demo/
/storage/framework/budget-proof/ /storage/framework/budget-proof/
/storage/framework/payables/ /storage/framework/payables/
/packages/purchasing/.update.lock
/packages/purchasing/history/
/packages/purchasing/current.json
@@ -0,0 +1,55 @@
<?php
namespace App\Modules\Identity\Http;
use App\Shared\Context\{CompanyContext,ModuleNavigation,ModuleRecords};
use App\Modules\Identity\Services\Permissions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class ModuleRecordsController
{
public function index(Request $r,CompanyContext $context,Permissions $permissions)
{
$s=$context->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]);
}
}
@@ -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.'); 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 = '<nav aria-label="Contexto SGF" style="padding:12px;background:#edf4f3;color:#163e38"><strong>Módulo activo: Compras</strong> · <a href="/" data-sgf-module-back>Voltar à navegação SGF</a></nav><script src="/sgf-module-context.js" defer></script>';
$response->setContent(preg_replace('/(<body\b[^>]*>)/i', '$1'.$bar, $response->getContent(), 1));
}
return $response;
} finally { } finally {
$this->context->clear(); $this->context->clear();
} }
+1 -1
View File
@@ -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 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. // 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([ DB::table('identity_audit')->insert([
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant, 'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject, 'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
@@ -43,7 +43,15 @@ final class IdentityWorkflow
$grants = DB::table('permission_grants')->where('user_id', $user->id)->where('company_id', $company->id) $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(); ->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), $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) { } catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
if ($exception->getStatusCode() !== 403) { throw $exception; } if ($exception->getStatusCode() !== 403) { throw $exception; }
$request->session()->forget('company_context'); $request->session()->forget('company_context');
@@ -54,14 +62,26 @@ final class IdentityWorkflow
public function select(Request $request): array 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; $unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
// Selection is explicit, never silently carries the old unit into another company. // 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); $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'], $module = $data['module_id'] ?? null;
'unit_id' => $unit, 'token' => bin2hex(random_bytes(24))]); 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.');
Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit); $previous = $request->session()->get('company_context.module_id');
return $this->snapshot($request); $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 public function renameCompany(Request $request, int $company): void
+4 -4
View File
@@ -36,7 +36,7 @@ final class CatalogueAccounting
public function listing(int $actor): array public function listing(int $actor): array
{ {
$scope=$this->scope($actor); $rows=$this->query($scope)->orderBy('id')->get()->map(fn($r)=>$this->decode($r))->all(); $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]; 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 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'])) { if(isset($data['service_id'])) {
$data['service_id']=(int)$data['service_id']; $data['service_id']=(int)$data['service_id'];
$this->permissions->require($this->context->userId(),$scope['tenant_id'],$scope['company_id'],'services.view'); $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') { if($kind==='account') {
$data['movable']=(bool)$data['movable']; $data['movable']=(bool)$data['movable'];
@@ -166,7 +166,7 @@ final class CatalogueAccounting
} }
private function resolveInternal(int $service,string $date,array $scope): array 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]; $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); $override=$this->select($scope,'service_override','service:'.$service,$date);
$link=$this->select($scope,'service_link','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 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'); $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); 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'))];}; $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(); if(DB::transactionLevel())return $read();
+4 -4
View File
@@ -37,7 +37,7 @@ final class DocumentPreparation
{ {
if ($kind === 'payment') { return $id ? $this->query($kind, $scope)->where('id', $id)->first() : null; } if ($kind === 'payment') { return $id ? $this->query($kind, $scope)->where('id', $id)->first() : null; }
$table = $kind === 'issuer' ? 'companies' : ($kind === 'client' ? 'a_clients' : 'a_'.$kind); $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']); } 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; $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'); $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 === '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 === '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); return $this->workflow->list($kind, $actor);
} }
@@ -124,7 +124,7 @@ final class DocumentPreparation
public function snapshot(int $draft, int $actor): ?array public function snapshot(int $draft, int $actor): ?array
{ {
$scope = $this->context->identifiers(); $scope['unit_id'] = $this->context->unitId(); $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) { $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)); $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(); })->get();
@@ -148,7 +148,7 @@ final class DocumentPreparation
$row = $this->query('service_drafts', $scope)->where('record_id', $source)->first(); $row = $this->query('service_drafts', $scope)->where('record_id', $source)->first();
if (! $row) { return; } if (! $row) { return; }
$data = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR); $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]; $data['draft_version'] = 1; $data['source_preparation'] = ['id' => $row->id, 'version' => $row->version];
$encoded = json_encode($data, JSON_THROW_ON_ERROR); $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()]); $id = DB::table('doc_records')->insertGetId($scope + ['kind' => 'billing_drafts', 'record_id' => $billing, 'version' => 1, 'data' => $encoded, 'created_at' => now(), 'updated_at' => now()]);
+1 -1
View File
@@ -23,7 +23,7 @@ final class DraftValues
{ {
$result = []; $result = [];
foreach ($lines as $index => $line) { 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.'); abort_unless($service, 403, 'Serviço fora da empresa autorizada.');
$result[] = ['service_id' => (int) $service->id, 'service_name' => $service->name, $result[] = ['service_id' => (int) $service->id, 'service_name' => $service->name,
'description' => $line['description'] ?? null, 'description' => $line['description'] ?? null,
@@ -10,7 +10,11 @@ use Illuminate\Http\Request;
final class ServiceController extends Controller 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) public function show(Request $request, string $area, int $id, ServiceWorkflow $workflow)
{ {
$record = $workflow->show($area, $id, $request->user()->id); $record = $workflow->show($area, $id, $request->user()->id);
@@ -17,6 +17,7 @@ final class InternalConfirmation
public function show(int $draft, int $actor): ?array public function show(int $draft, int $actor): ?array
{ {
$scope = $this->workflow->scope('billing_drafts', $actor, 'view'); $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(); $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; } if (! $row) { return null; }
$history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $row->id)->value('id'); $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.'); 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]; 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($billing, 403, 'Rascunho fora do âmbito autorizado.');
abort_unless((int) $billing->version === $version, 409, 'A versão mudou; reabra o rascunho.'); 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(); $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]; $result = ['id' => $existing->id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$existing->id, 'event_id' => $event];
} else { } else {
$data = json_decode($billing->data, true, 32, JSON_THROW_ON_ERROR); $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.'); 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($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.'); 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); $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.'); } 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.']; $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 = []; $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; $data['client_name'] = $client->name;
$snapshot = ['label' => self::LABEL, 'scope' => $scope, 'billing_draft' => $this->record($billing), 'service_draft' => $this->record($source), $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), 'client' => $this->record($client), 'services' => $catalogue, 'data' => $data, 'base_amount' => DraftValues::base($data),
@@ -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.'); 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['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($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_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.'); 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(); $profile=DB::table('service_calculation_profiles')->where($scope)->where('id',$p->profile_id)->where('identifier',$p->identifier)->where('version',$p->version)->first();
+1 -1
View File
@@ -68,7 +68,7 @@ final class ServiceCalculation
'data' => $b['data'], 'prepared' => $p['data'], 'profile_id' => (int) $profile->id, 'data' => $b['data'], 'prepared' => $p['data'], 'profile_id' => (int) $profile->id,
'profile_version' => (int) $profile->version, 'profile_identifier' => $profile->identifier, 'profile_version' => (int) $profile->version, 'profile_identifier' => $profile->identifier,
'profile' => json_decode($profile->definition, true, 32, JSON_THROW_ON_ERROR), 'policy' => self::POLICY, '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)); } private function fingerprint(array $input): string { return hash('sha256', json_encode($input, JSON_THROW_ON_ERROR)); }
+3 -3
View File
@@ -69,7 +69,7 @@ final class ServiceFiscal
$version=(int)($last?->version ?? 0)+1; $version=(int)($last?->version ?? 0)+1;
if ($in['kind']==='profile') { 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']); $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.'); 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.'); 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.'); 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 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.'); 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.'); 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 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] ?? []; $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($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.'); 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.'); 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; return $d;
} }
+4 -4
View File
@@ -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'=>[]]; if(!ServiceCalculation::isolated()) return ['real_blocked'=>true,'test_enabled'=>false,'notice'=>'Emissão real bloqueada: conformidade ainda não demonstrada.','proposals'=>[],'invoices'=>[]];
TestingDatabaseGuard::assertSafe(); TestingDatabaseGuard::assertSafe();
if(!Schema::hasTable('si_invoices')) return ['real_blocked'=>true,'notice'=>'Migração documental ainda não aplicada.','proposals'=>[],'invoices'=>[]]; 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 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=$this->snapshot($p,$number,$instant);
$snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes. $snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes.
$bytes=$this->pdf->render($snapshot); $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()]); $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']); 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]); 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 public function show(int $id,int $actor): array
{ {
self::guard(); $s=$this->scope($actor,'service_invoices.view'); 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); $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()]; 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 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; $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 public function requestCorrection(int $id,int $actor,array $input): array
+2 -2
View File
@@ -90,7 +90,7 @@ final class ServiceReceivables
$right=$kind==='correction'?'correction_request':'prepare'; $right=$kind==='correction'?'correction_request':'prepare';
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation'); 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) { 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.'); abort_unless($inv,403,'Documento de origem não autorizado.');
$snapshot=json_decode($inv->snapshot,true,32,JSON_THROW_ON_ERROR); $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.'); 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) { return DB::transaction(function()use($s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail(); DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$records=DB::table('sr_records')->where($s)->orderBy('id')->get(); $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. */ /** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
+13 -6
View File
@@ -25,7 +25,7 @@ final class ServiceWorkflow
private function query(string $area, array $scope) 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 public function list(string $area, int $actor): array
@@ -74,7 +74,9 @@ final class ServiceWorkflow
$scope = $this->scope($area, $actor, $action); $scope = $this->scope($area, $actor, $action);
$selectedUnit = $this->context->unitId(); $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.'); 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 { 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(); 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); $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); $data = $values['data']; $values['data'] = json_encode($data === [] ? new \stdClass() : $data, JSON_THROW_ON_ERROR);
$values += ['version' => $version, 'updated_at' => now()]; $values += ['version' => $version, 'updated_at' => now()];
if ($id) { $this->query($area, $scope)->where('id', $id)->update($values); } 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(); $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); } if ($area === 'billing_drafts' && ! $old) { app(DocumentPreparation::class)->inheritToBilling((int) $record->source_id, $id, $actor, $scope); }
$snapshot = (array) $record; $snapshot['data'] = $data; $snapshot = (array) $record; $snapshot['data'] = $data;
$pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : []; $pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : [];
if ($area === 'billing_drafts') { 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.'; } 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; $snapshot['pending'] = $pending;
@@ -147,7 +154,7 @@ final class ServiceWorkflow
if (!$old && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) { 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.'); 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.'); abort_unless($source, 403, 'Prestação fora da unidade autorizada.');
$reload = ! $old || ($input['reload_source'] ?? false); $reload = ! $old || ($input['reload_source'] ?? false);
if ($reload) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'service_drafts.view', $scope['unit_id']); } 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 { } else {
$clientId = $input['client_id']; $lines = $input['lines']; $currency = $input['currency'] ?? null; $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.'); abort_unless($client, 403, 'Cliente fora da empresa autorizada.');
$data = ['description' => $input['description'] ?? ($base['description'] ?? ($oldData['description'] ?? null)), 'currency' => $currency, $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']; 'lines' => DraftValues::lines($lines, $scope), 'client_name' => $client->name, 'tax_status' => 'pending', 'accounting_status' => 'not_processed'];
+3 -3
View File
@@ -35,13 +35,13 @@ final class WorkshopBilling {
} }
private function compose(int $order,array $input,array $s,bool $allowClaim=false): array { 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.'); $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=[]; abort_unless(count($input['lines'])>0,422);$seen=[];$lines=[];
foreach($input['lines'] as $line){ 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']); $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; $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.'); 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.'); $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); $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'])]; $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(); $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){}} 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;}}} $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()];});
} }
} }
+5 -5
View File
@@ -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]; 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 { 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.'); 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); 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.'); 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.'); 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); $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.');} 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.'); $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'=>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()]; 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 { 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=[]; 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']); 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.'); 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.'); $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']]); $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; }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=[]; $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);} 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()];} $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()];});
} }
} }
+2 -2
View File
@@ -79,7 +79,7 @@ final class WorkshopWorkflow
self::guard(); 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']); $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) { 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.'); 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; $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.'); 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)]; $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():[]; $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')); 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]; return ['blocked'=>false,'notice'=>self::NOTICE,'policy'=>self::POLICY,'orders'=>$orders,'clients'=>$clients->get(['id','name'])->all(),'technicians'=>$techs];
}); });
+3
View File
@@ -17,6 +17,9 @@ class AppServiceProvider extends ServiceProvider
{ {
require_once base_path('packages/budget-control/bootstrap.php'); require_once base_path('packages/budget-control/bootstrap.php');
require_once base_path('packages/payables/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()); $this->app->scoped(CompanyContext::class, fn () => new CompanyContext());
} }
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Shared\Context;
use Illuminate\Support\Facades\DB;
final class ModuleNavigation
{
public const LABELS = ['commercial'=>'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;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Shared\Context;
use App\Modules\Identity\Services\Audit;
use Illuminate\Support\Facades\DB;
/** Explicit, append-only associations reuse the existing immutable audit store.
* Legacy rows are never assigned from their names, creators or absence of a link.
*/
final class ModuleRecords
{
public const AREAS = ['clients','services','service_drafts','billing_drafts'];
public static function active(): ?string { return request()->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]);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Shared\Context;
use Illuminate\Http\Request;
final class RequireModuleContext
{
public function check(Request $request, CompanyContext $context): void
{
$owner = ModuleNavigation::owner($request->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.');
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
return [
// No entitlement store exists yet. Preserve the installed composition; explicit
// tenant/company restrictions can only narrow it. Never activates a package.
'contracted' => ['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))),
];
+64
View File
@@ -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.
+70
View File
@@ -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.
+18
View File
@@ -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.
+6
View File
@@ -1,5 +1,11 @@
# Compras e núcleo financeiro comum — desenho e transição # 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. **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. **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.
+232
View File
@@ -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 <raiz-SGF>` lê os dois ficheiros de identidade do host e produz uma sobreposição de revisão em `storage/app/private/candidates/ACCESS-CANDIDATE-<hash>`, 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 <raiz-SGF>` lê o host e produz `OPERATIONAL-CANDIDATE-<hash>`, 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 "<raiz-SGF>/scripts/sgf.ps1" php "<candidata>/scripts/sgf-internal-install.php" plan "<raiz-SGF>"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "<raiz-SGF>/scripts/sgf.ps1" php "<candidata>/scripts/sgf-internal-install.php" migrate "<raiz-SGF>" <target-sha256> "<backup.dump>" <backup-sha256> "<operador-nominativo>" <manifest-sha256>
```
`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=<arquivo-backup.dump> --dbname=<base-origem-conferida>
pg_restore --list <arquivo-backup.dump>
createdb --owner=<proprietario-autorizado> <nova-base-vazia-de-recuperacao>
pg_restore --no-owner --no-privileges --exit-on-error --dbname=<nova-base-vazia-de-recuperacao> <arquivo-backup.dump>
```
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 -- <canal>`. 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 <raiz-SGF> <versao.zip> <sha256-manifesto>
php scripts/purchasing-release.php install <raiz-SGF> <versao.zip> <sha256-manifesto>
php scripts/purchasing-release.php status <raiz-SGF>
php scripts/purchasing-release.php rollback <raiz-SGF>
```
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/<versão>`; `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.
@@ -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.
+2
View File
@@ -4,6 +4,8 @@ Implementado o incremento seguinte às obrigações, nas duas composições, com
## Responsabilidade e código ## 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. 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. 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.
@@ -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
}
@@ -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"
}
@@ -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"
]
}
@@ -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"
}
}
@@ -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
}
+36
View File
@@ -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
}
+35
View File
@@ -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
}
+40
View File
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
+2 -1
View File
@@ -5,7 +5,8 @@
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort", "dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
"check:navigation": "node scripts/home-navigation-check.mjs", "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": { "dependencies": {
"react": "19.2.9", "react": "19.2.9",
+11
View File
@@ -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.
+295
View File
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace Tempo\Distribution;
use RuntimeException;
use ZipArchive;
/** Offline, immutable code releases. Never connects to a database or grants access. */
final class Release
{
private const LIMIT = 33554432;
private static function fail(bool $condition, string $message): void
{
if (! $condition) {
throw new RuntimeException($message);
}
}
private static function path(string $path): bool
{
return preg_match('~^(bootstrap\.php|module\.json|README\.md|src/[A-Za-z0-9_/]+\.php)$~D', $path) === 1
&& ! str_contains($path, '..') && ! str_contains($path, '//');
}
private static function json(string $path): array
{
self::fail(is_file($path) && ! is_link($path) && filesize($path) < self::LIMIT, 'Manifesto inexistente ou inválido.');
$value = json_decode(file_get_contents($path), true, 64, JSON_THROW_ON_ERROR);
self::fail(is_array($value), 'Manifesto inválido.');
return $value;
}
private static function write(string $path, string $content): void
{
self::fail(! is_link($path), 'Destino de gravação não pode ser um link.');
self::fail(file_put_contents($path, $content, LOCK_EX) === strlen($content), 'Não foi possível gravar o artefacto.');
}
private static function encode(array $value): string
{
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
}
private static function version(string $version): void
{
self::fail(preg_match('/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/D', $version) === 1, 'Versão inválida.');
}
/** Publish a patch version only when canonical package contents have changed. */
public static function publish(string $source, string $channel): array
{
$source = realpath($source);
self::fail($source !== false && is_dir($source), 'Pacote de origem inexistente.');
$module = self::json($source.'/module.json');
self::fail(($module['package'] ?? '') === 'tempo/purchasing' && ($module['host_contract'] ?? null) === 1
&& ($module['database_changes'] ?? null) === false && ($module['commercial_permissions'] ?? null) === false, 'Contrato de distribuição incompatível.');
$contents = [];
$files = [];
$size = 0;
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS)) as $file) {
self::fail(! $file->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);
}
}
}
@@ -0,0 +1,12 @@
<?php
use Tempo\Distribution\Release;
// Installed host loader. No runtime dependency on the source checkout.
require_once dirname(__DIR__).'/module-updates/Release.php';
$purchasingRelease = Release::current(dirname(__DIR__, 2));
if ($purchasingRelease === null) {
throw new RuntimeException('Instale uma versão verificada do módulo de Compras.');
}
require_once $purchasingRelease['path'].'/bootstrap.php';
unset($purchasingRelease);
@@ -0,0 +1,11 @@
{
"schema": 1,
"canonical_source": "Compras/packages/module-updates and Compras/scripts",
"files": {
"packages/module-updates/Release.php": "c64020ad7fb8bd82348d4f1b0d63ff7ac8209ea1005c90d86fe1144309e41161",
"packages/purchasing/bootstrap.php": "f0b2e9f06e29c767609f58ca6fe410af4850bc9066076610fa7fb513d286ecb2",
"scripts/purchasing-release.php": "c1420370252302d3da2d9981ead5a288eec9be35f0797e6df0643f9a8b9844be",
"scripts/purchasing-probe.php": "b4b505624e31f1bed4985e800a6f31f47c05e29dfabb0f296504521b5121c57d",
"scripts/purchasing-client-check.php": "57f1195e1e40bd304164740f4ae0b45424858587f9fd6fcf26eb143c8d62becc"
}
}
+1
View File
@@ -8,4 +8,5 @@ require_once __DIR__.'/src/PaymentDecisions.php';
require_once __DIR__.'/src/PaymentDecisionSchema.php'; require_once __DIR__.'/src/PaymentDecisionSchema.php';
require_once __DIR__.'/src/SupplierSettlements.php'; require_once __DIR__.'/src/SupplierSettlements.php';
require_once __DIR__.'/src/SettlementSchema.php'; require_once __DIR__.'/src/SettlementSchema.php';
require_once __DIR__.'/src/ReversalSchema.php';
require_once __DIR__.'/src/SettlementHttp.php'; require_once __DIR__.'/src/SettlementHttp.php';
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Explicit QA upgrade; never run automatically on existing demos or operational databases. */
final class ReversalSchema
{
public const TABLES = ['ps_reversal_proposals', 'ps_reversals'];
public static function install(): void
{
$db = DB::connection();
if (! app()->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);
});
}
}
+11 -2
View File
@@ -2,6 +2,7 @@
namespace Tempo\Finance\Payables; namespace Tempo\Finance\Payables;
use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@@ -35,6 +36,9 @@ abstract class SettlementHttp extends Controller
return DB::transaction(function () use ($r): array { return DB::transaction(function () use ($r): array {
$s = app(static::SERVICE); $s = app(static::SERVICE);
$user = $r->user(); $user = $r->user();
if (! $user instanceof User) {
abort(401);
}
$settlements = $s->engine($user)->settlements()->listing(); $settlements = $s->engine($user)->settlements()->listing();
$base = $s->listing($user); $base = $s->listing($user);
foreach ($base['obligations'] as &$o) { foreach ($base['obligations'] as &$o) {
@@ -43,12 +47,13 @@ abstract class SettlementHttp extends Controller
} }
unset($o); unset($o);
$permissions = []; $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); $permissions[$action] = $s->can($user, $action);
} }
return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'], return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'],
'allocation_proposals' => $settlements['proposals'], 'allocations' => $settlements['allocations'], 'events' => $settlements['events'], '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]; 'permissions' => $permissions, 'current_actor' => $user->id];
}); });
} }
@@ -61,7 +66,11 @@ abstract class SettlementHttp extends Controller
public function page(Request $r): Response public function page(Request $r): Response
{ {
$data = $this->data($r); $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', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token'), 'api' => '/api/supplier-payments',
'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables']; 'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables'];
+116 -6
View File
@@ -135,12 +135,33 @@ final class SupplierSettlements
return 0; 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 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<string,mixed> $data /** @param array<string,mixed> $data
@@ -148,7 +169,8 @@ final class SupplierSettlements
*/ */
public function command(string $action, array $data, string $key): array 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)) { 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.'); throw new \InvalidArgumentException('Acção ou chave inválida.');
} }
@@ -164,6 +186,7 @@ final class SupplierSettlements
$result = match ($action) { $result = match ($action) {
'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data), 'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data),
'allocation-prepare' => $this->prepareAllocation($data), 'allocation-decide' => $this->decideAllocation($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)) { 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.'); 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)); $this->referenceScope('allocation_aliases', 'reference_key', $this->key($reference));
// Semantic recovery precedes balance checks: an already-applied plan remains recoverable. // 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'); $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->isNotEmpty()) {
if ($old->count() !== 1 || (int) $old->first()->payment_id !== (int) $p->id || $old->first()->plan_hash !== $hash) { 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.'); throw new \DomainException('Referência de afectação incompatível.');
@@ -297,7 +327,8 @@ final class SupplierSettlements
$this->checkPlan($p, $plan, true); $this->checkPlan($p, $plan, true);
$id = $this->db->table('ps_proposals')->insertGetId($this->scope + ['payment_id' => $p->id, 'reference_key' => $this->key($reference), $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, '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]); $this->event((int) $p->id, 'supplier_allocation.proposed', ['proposal_id' => $id, 'plan_hash' => $hash]);
return ['proposal_id' => $id]; return ['proposal_id' => $id];
@@ -316,7 +347,11 @@ final class SupplierSettlements
throw new \DomainException('Montante fora dos limites.'); throw new \DomainException('Montante fora dos limites.');
} }
$total += $line['amount_minor']; $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.'); 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']); $reason = $this->reason($d['reason']);
$plan = json_decode($proposal->plan, true, 64, JSON_THROW_ON_ERROR); $plan = json_decode($proposal->plan, true, 64, JSON_THROW_ON_ERROR);
$old = $this->q('allocation_decisions')->where('proposal_id', $proposal->id)->first(); $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'); $this->checkPlan($p, $plan, ! $old && $d['decision'] === 'confirmed');
if ($old) { if ($old) {
if ($old->decision !== $d['decision'] || $old->reason !== $reason) { if ($old->decision !== $d['decision'] || $old->reason !== $reason) {
@@ -363,6 +401,75 @@ final class SupplierSettlements
return ['proposal_id' => (int) $proposal->id, 'decision_id' => $id]; 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<string,mixed> $d
* @return array<string,int>
*/
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<string,mixed> $d
* @return array<string,int>
*/
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<string,mixed> */ /** @return array<string,mixed> */
public function listing(): 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(); $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(), 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]; 'events' => $this->q('events')->orderBy('id')->get()->all(), 'current_actor' => $this->actor];
}); });
} }
+12 -2
View File
@@ -19,14 +19,24 @@ body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-widt
<?php if ($data['permissions']['allocation_prepare'] && $confirmed && $eligible): ?><section><h2>3. Propor afectações</h2><form data-action="allocation-prepare"><label>Pagamento confirmado<select name="payment_id"><?php foreach ($confirmed as $p): ?><option value="<?= $e($p['id']) ?>">#<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?> · Por afectar <?= $money($p['unallocated_minor']) ?></option><?php endforeach ?></select></label><label>Referência estável da afectação<input name="reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-ALLOC-001"></label><div data-lines><div class="line" data-line><label>Obrigação<select data-obligation><?php $options(); ?></select></label><label>Valor a afectar (MZN)<input data-amount required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><button type="button" data-remove>Remover linha</button></div></div><button type="button" data-add>Adicionar outra obrigação</button><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><p>Mesmo fornecedor e moeda. O excedente fica por afectar. Não existe reembolso/crédito automático nem edição de uma afectação confirmada.</p><button>Submeter afectações sem abater saldo</button></form></section><?php endif ?> <?php if ($data['permissions']['allocation_prepare'] && $confirmed && $eligible): ?><section><h2>3. Propor afectações</h2><form data-action="allocation-prepare"><label>Pagamento confirmado<select name="payment_id"><?php foreach ($confirmed as $p): ?><option value="<?= $e($p['id']) ?>">#<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?> · Por afectar <?= $money($p['unallocated_minor']) ?></option><?php endforeach ?></select></label><label>Referência estável da afectação<input name="reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-ALLOC-001"></label><div data-lines><div class="line" data-line><label>Obrigação<select data-obligation><?php $options(); ?></select></label><label>Valor a afectar (MZN)<input data-amount required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><button type="button" data-remove>Remover linha</button></div></div><button type="button" data-add>Adicionar outra obrigação</button><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><p>Mesmo fornecedor e moeda. O excedente fica por afectar. Não existe reembolso/crédito automático nem edição de uma afectação confirmada.</p><button>Submeter afectações sem abater saldo</button></form></section><?php endif ?>
<section id="allocation-proposals"><h2>4. Propostas de afectação</h2><p>A proposta não reduz a dívida. Troque para Validar para confirmar a afectação com outra identidade.</p><?php foreach ($data['allocation_proposals'] as $p): ?><article><h3>Proposta #<?= $e($p['id']) ?> · <?= $e($p['reference']) ?> · Pagamento #<?= $e($p['payment_id']) ?></h3><p>Preparador #<?= $e($p['prepared_by']) ?> · <?= $e($p['reason']) ?></p><ul><?php foreach (json_decode($p['plan'], true) as $line): ?><li>Obrigação #<?= $e($line['obligation_id']) ?>: <?= $money($line['amount_minor']) ?> · versão <?= $e($line['source_revision']) ?></li><?php endforeach ?></ul><p>Estado: <?= $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?></p> <section id="allocation-proposals"><h2>4. Propostas de afectação</h2><p>A proposta não reduz a dívida. Troque para Validar para confirmar a afectação com outra identidade.</p><?php foreach ($data['allocation_proposals'] as $p): ?><article><h3>Proposta #<?= $e($p['id']) ?> · <?= $e($p['reference']) ?> · Pagamento #<?= $e($p['payment_id']) ?></h3><p>Preparador #<?= $e($p['prepared_by']) ?> · <?= $e($p['reason']) ?></p><ul><?php foreach (json_decode($p['plan'], true) as $line): ?><li>Obrigação #<?= $e($line['obligation_id']) ?>: <?= $money($line['amount_minor']) ?> · versão <?= $e($line['source_revision']) ?></li><?php endforeach ?></ul><p>Estado: <?= $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?></p>
<?php if ($data['permissions']['allocation_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="<?= $e($p['id']) ?>"><label>Decisão<select name="decision"><option value="confirmed">Confirmar estas afectações</option><option value="rejected">Rejeitar</option></select></label><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar decisão e revalidar saldos</button></form><?php endif ?></article><?php endforeach ?></section> <?php if ($data['permissions']['allocation_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="<?= $e($p['id']) ?>"><label>Decisão<select name="decision"><option value="confirmed">Confirmar estas afectações</option><option value="rejected">Rejeitar</option></select></label><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar decisão e revalidar saldos</button></form><?php endif ?></article><?php endforeach ?></section>
<section id="reversals"><h2>5. Reversão integral e nova afectação</h2><p>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.</p>
<?php if (! ($data['reversals_available'] ?? false)): ?><p>Actualização de reversões ainda não aplicada neste ensaio. Os saldos e o percurso anterior permanecem disponíveis.</p><?php else: ?>
<?php foreach ($data['allocations'] as $a): $reversal = null; $proposal = null; foreach ($data['reversals'] as $r) if ((int) $r->allocation_id === (int) $a->id) $reversal = $r; foreach ($data['reversal_proposals'] as $r) if ((int) $r->allocation_id === (int) $a->id) $proposal = $r; ?>
<article><h3>Afectação #<?= $e($a->id) ?> · Pagamento #<?= $e($a->payment_id) ?> · Obrigação #<?= $e($a->obligation_id) ?></h3><p>Original preservado: <?= $money($a->amount_minor) ?> · Versão <?= $e($a->source_revision) ?></p>
<?php if ($reversal): ?><p>Revertida integralmente por movimento #<?= $e($reversal->id) ?> · <?= $money($reversal->amount_minor) ?> · actor #<?= $e($reversal->actor_id) ?> · <?= $e($reversal->reason) ?></p>
<?php elseif ($proposal): ?><p>Proposta de reversão #<?= $e($proposal->id) ?> · Preparador #<?= $e($proposal->prepared_by) ?> · <?= $e($proposal->reason) ?> · Sem efeito no saldo</p>
<?php if (($data['permissions']['reversal_confirm'] ?? false) && (int) $proposal->prepared_by !== $data['current_actor']): ?><form data-action="reversal-confirm"><input type="hidden" name="proposal_id" value="<?= $e($proposal->id) ?>"><label>Motivo da confirmação independente<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar reversão integral de <?= $money($a->amount_minor) ?></button></form><?php endif ?>
<?php elseif ($data['permissions']['reversal_prepare'] ?? false): ?><form data-action="reversal-prepare"><input type="hidden" name="allocation_id" value="<?= $e($a->id) ?>"><input type="hidden" name="source_revision" value="<?= $e($a->source_revision) ?>"><label>Motivo obrigatório da reversão integral<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Propor reversão integral sem alterar saldo</button></form><?php endif ?>
<?php if ($proposal): ?><details><summary>Proposta original de reversão preservada</summary><pre><?= $e(json_encode($proposal, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details><?php endif ?></article><?php endforeach ?>
<?php endif ?></section>
<section><h2>Histórico durável</h2><?php foreach ($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section> <section><h2>Histórico durável</h2><?php foreach ($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
<p>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.</p> <p>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.</p>
</main><script> </main><script>
const settings=<?= json_encode($settings, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>; const settings=<?= json_encode($settings, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>;
document.querySelector('#reload').onclick=()=>location.reload(); document.querySelector('#reload').onclick=()=>location.reload();
for(const selector of document.querySelectorAll('[data-origin]')){const sync=()=>selector.form.elements.source_revision.value=selector.selectedOptions[0]?.dataset.revision??'';selector.onchange=sync;sync();} for(const selector of document.querySelectorAll('[data-origin]')){const sync=()=>selector.form.elements.source_revision.value=selector.selectedOptions[0]?.dataset.revision??'';selector.onchange=sync;sync();}
for(const form of document.querySelectorAll('[data-action]')){ for(const form of document.querySelectorAll('[data-action]')){
const add=form.querySelector('[data-add]');if(add){const lines=form.querySelector('[data-lines]');const template=lines.firstElementChild.cloneNode(true);add.onclick=()=>{if(lines.children.length<50)lines.append(template.cloneNode(true));};lines.onclick=e=>{if(e.target.matches('[data-remove]')&&lines.children.length>1)e.target.closest('[data-line]').remove();};} const add=form.querySelector('[data-add]');if(add){const lines=form.querySelector('[data-lines]');const template=lines.firstElementChild.cloneNode(true);add.onclick=()=>{if(lines.children.length<50)lines.append(template.cloneNode(true));};lines.onclick=e=>{if(e.target.matches('[data-remove]')&&lines.children.length>1)e.target.closest('[data-line]').remove();};}
form.onsubmit=async e=>{e.preventDefault();let body=Object.fromEntries(new FormData(form));for(const key of ['obligation_id','payment_id','proposal_id'])if(key in body)body[key]=Number(body[key]);if(form.dataset.action==='allocation-prepare')body.allocations=[...form.querySelectorAll('[data-line]')].map(line=>{const select=line.querySelector('[data-obligation]');return {obligation_id:Number(select.value),source_revision:select.selectedOptions[0]?.dataset.revision??'',amount:line.querySelector('[data-amount]').value};});const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true);document.querySelector('#error').textContent='';document.querySelector('#status').textContent='A processar. Aguarde antes de repetir.';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:signature});const d=await r.json();if(!r.ok){document.querySelector('#error').textContent=Object.values(d.errors??{}).flat().join(' ')||d.message||'Não foi possível concluir; consulte o histórico.';document.querySelector('#error').focus();return;}location.hash=form.dataset.action==='allocation-decide'?'obligation-balances':form.dataset.action==='allocation-prepare'?'allocation-proposals':'payments';location.reload();}catch{document.querySelector('#error').textContent='Não foi possível confirmar o resultado. Actualize a consulta e confira o histórico antes de repetir.';document.querySelector('#error').focus();}finally{document.querySelector('#status').textContent='';buttons.forEach(b=>b.disabled=false);}}; form.onsubmit=async e=>{e.preventDefault();let body=Object.fromEntries(new FormData(form));for(const key of ['obligation_id','payment_id','proposal_id','allocation_id'])if(key in body)body[key]=Number(body[key]);if(form.dataset.action==='allocation-prepare')body.allocations=[...form.querySelectorAll('[data-line]')].map(line=>{const select=line.querySelector('[data-obligation]');return {obligation_id:Number(select.value),source_revision:select.selectedOptions[0]?.dataset.revision??'',amount:line.querySelector('[data-amount]').value};});const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true);document.querySelector('#error').textContent='';document.querySelector('#status').textContent='A processar. Aguarde antes de repetir.';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:signature});const d=await r.json();if(!r.ok){document.querySelector('#error').textContent=Object.values(d.errors??{}).flat().join(' ')||d.message||'Não foi possível concluir; consulte o histórico.';document.querySelector('#error').focus();return;}location.hash=form.dataset.action.startsWith('reversal-')?'reversals':form.dataset.action==='allocation-decide'?'obligation-balances':form.dataset.action==='allocation-prepare'?'allocation-proposals':'payments';location.reload();}catch{document.querySelector('#error').textContent='Não foi possível confirmar o resultado. Actualize a consulta e confira o histórico antes de repetir.';document.querySelector('#error').focus();}finally{document.querySelector('#status').textContent='';buttons.forEach(b=>b.disabled=false);}};
} }
</script></body></html> </script></body></html>
+12
View File
@@ -0,0 +1,12 @@
<?php
use Tempo\Distribution\Release;
// Installed host loader. No runtime dependency on the source checkout.
require_once dirname(__DIR__).'/module-updates/Release.php';
$purchasingRelease = Release::current(dirname(__DIR__, 2));
if ($purchasingRelease === null) {
throw new RuntimeException('Instale uma versão verificada do módulo de Compras.');
}
require_once $purchasingRelease['path'].'/bootstrap.php';
unset($purchasingRelease);
+9
View File
@@ -0,0 +1,9 @@
# Purchasing: fonte única de capacidades transferíveis
A origem deste pacote é Compras. O SGF recebe artefactos versionados desta origem, sem depender da pasta de desenvolvimento de Compras e sem manter outra implementação.
Primeira capacidade extraída: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes). O cálculo não lê bases, não movimenta stock nem cria encomendas. Conserva as convenções e unidades do percurso Compras. Requer Carbon 3 e Brick Math já presentes nos dois hosts; não substitui dependências.
Os restantes percursos de Compras ainda não fazem parte deste pacote. A presença do pacote não equivale a transferência integral, integração de utilizadores/empresas, concessão de permissões ou activação de módulos comerciais. O SGF não recebe contratações de pessoas para terceiros.
Após melhorias neste pacote, publicar pelo mecanismo `purchasing-release.php`. O manifesto regista versões e hashes; o SGF confere e instala os mesmos bytes. Nunca editar uma versão distribuída no SGF: corrigir a origem e publicar uma versão seguinte.
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/ReplenishmentProjection.php';
+22
View File
@@ -0,0 +1,22 @@
{
"schema": 1,
"package": "tempo/purchasing",
"version": "0.1.0",
"source_sha256": "2bb77aa1b723e0f616393aee739929399151548089693f38bdaffb0d75f3b186",
"module": {
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": [
"inventory.replenishment_projection"
],
"database_changes": false,
"commercial_permissions": false
},
"files": {
"README.md": "ad51be37229d2891b8e9c3a5ca5f6edc90f69feb11b9fa6f5f384a09a35d138e",
"bootstrap.php": "829c9f55ea850e64203927302a8daa1ebee791fe2d7763383b2b1a69cfe5f3f5",
"module.json": "f70d4553eec85f6f1f6f50d6c3e7a22678649bfb17248b45342787c405eb3752",
"src/ReplenishmentProjection.php": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": ["inventory.replenishment_projection"],
"database_changes": false,
"commercial_permissions": false
}
@@ -0,0 +1,115 @@
<?php
namespace Tempo\Purchasing\Supply;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Carbon\CarbonImmutable;
/** Quantities use integer thousandths, consistently with the existing stock ledger. */
final class ReplenishmentProjection
{
/** @param array<string,mixed> $policy
* @param list<array{date:string,quantity:int,additional?:bool}> $reservations
* @param list<array{date:string,quantity:int,confirmed:bool}> $receipts
* @param list<array{date:string,quantity:int}> $fulfilledNeeds
* @param list<array{quantity:int,last_usable_on:?string}>|null $lotBuckets
* @return array<string,mixed>
*/
public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array
{
$day = CarbonImmutable::parse($start)->startOfDay();
$daily = $policy['daily_milli'] ?? null;
$rate = $policy['daily_rate'] ?? null;
if ($rate !== null) {
if (! is_int($rate['numerator_milli']) || $rate['numerator_milli'] < 0 || ! is_int($rate['denominator_days']) || $rate['denominator_days'] < 1) {
throw new \InvalidArgumentException('A média histórica exige numerador não negativo e denominador positivo.');
}
$daily = 0;
}
if ($daily === null && $reservations === []) {
return ['available' => false, 'reason' => 'Previsão indisponível: configure consumo manual ou histórico validado.'];
}
$knownOnly = $daily === null;
$daily ??= 0;
$balance = $physical;
$risk = null;
$shortage = null;
$before = null;
$series = [];
$safety = $policy['safety_milli'];
if ($balance <= $safety) {
$risk = $day->toDateString();
$before = $balance;
}
for ($offset = 0; $offset < $policy['horizon_days']; $offset++) {
$date = $day->addDays($offset)->toDateString();
$expired = 0;
if ($lotBuckets !== null) {
foreach ($lotBuckets as &$bucket) {
if ($bucket['last_usable_on'] !== null && $bucket['last_usable_on'] < $date) {
$expired += $bucket['quantity'];
$bucket['quantity'] = 0;
}
}
unset($bucket);
}
$incoming = array_sum(array_column(array_filter($receipts, fn ($r) => $r['confirmed'] && $r['date'] === $date), 'quantity'));
$known = array_filter($reservations, fn ($r) => $r['date'] === $date);
$substitutes = array_sum(array_column(array_filter($known, fn ($r) => ! ($r['additional'] ?? false)), 'quantity'));
$additional = array_sum(array_column(array_filter($known, fn ($r) => $r['additional'] ?? false), 'quantity'));
$satisfied = array_sum(array_column(array_filter($fulfilledNeeds, fn ($n) => $n['date'] === $date), 'quantity'));
$expected = $daily;
if ($rate !== null) {
$cumulative = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset + 1)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$previous = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$expected = $cumulative - $previous;
}
$residualForecast = max(0, $expected - $satisfied - ($offset === 0 ? $consumedToday : 0));
$demand = max($residualForecast, $substitutes) + $additional;
$balance += $incoming - $demand - $expired;
if ($lotBuckets !== null) {
$remaining = $demand;
foreach ($lotBuckets as &$bucket) {
$take = min($remaining, $bucket['quantity']);
$bucket['quantity'] -= $take;
$remaining -= $take;
}
unset($bucket);
}
if ($risk === null && $balance <= $safety) {
$risk = $date;
$before = $balance;
}
if ($shortage === null && $balance < 0) {
$shortage = $date;
}
$series[] = ['date' => $date, 'balance_milli' => $balance, 'incoming_milli' => $incoming, 'demand_milli' => $demand, ...($lotBuckets !== null ? ['expired_milli' => $expired] : [])];
}
$base = $before === null ? 0 : max(0, $policy['target_milli'] - $before);
$quantity = $base === 0 ? 0 : max($base, $policy['minimum_milli']);
$multiple = $policy['pack_milli'];
$quantity = $quantity === 0 ? 0 : intdiv($quantity + $multiple - 1, $multiple) * $multiple;
$excess = $before === null ? 0 : max(0, $before + $quantity - $policy['maximum_milli']);
$futureExcess = 0;
if ($risk !== null && $quantity > 0) {
foreach ($series as $point) {
if ($point['date'] >= $risk) {
$futureExcess = max($futureExcess, $point['balance_milli'] + $quantity - $policy['maximum_milli']);
}
}
}
$mean = $rate === null ? $daily : (string) BigDecimal::of($rate['numerator_milli'])->dividedBy($rate['denominator_days'], 6, RoundingMode::HalfUp);
$reorder = $rate === null ? $daily * $policy['lead_days'] + $safety : BigDecimal::of($rate['numerator_milli'])->multipliedBy($policy['lead_days'])->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->plus($safety)->toInt();
return ['available' => true, 'method' => $knownOnly ? 'known_needs_only' : ($rate === null ? 'manual_calendar_days' : 'historical_mean_calendar_days'), 'demand_complete' => ! $knownOnly, 'physical_milli' => $physical,
'daily_milli' => $mean, 'reorder_point_milli' => $reorder,
'risk_date' => $risk, 'shortage_date' => $shortage,
'order_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'])->toDateString(),
'request_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'] + $policy['approval_days'])->toDateString(),
'base_milli' => $base, 'quantity_milli' => $quantity, 'excess_milli' => $excess,
'future_excess_milli' => $futureExcess, 'requires_exception' => $excess > 0 || $futureExcess > 0, 'series' => $series,
'convention' => 'Stock inicial na abertura; entradas confirmadas de manhã; consumo e nova reposição no fim do dia.'];
}
}
+13
View File
@@ -0,0 +1,13 @@
# Purchasing: fonte única de capacidades transferíveis
A origem deste pacote é Compras. O SGF recebe artefactos versionados desta origem, sem depender da pasta de desenvolvimento de Compras e sem manter outra implementação.
Primeira capacidade extraída: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes). O cálculo não lê bases, não movimenta stock nem cria encomendas. Conserva as convenções e unidades do percurso Compras. Requer Carbon 3 e Brick Math já presentes nos dois hosts; não substitui dependências.
Os restantes percursos de Compras ainda não fazem parte deste pacote. A presença do pacote não equivale a transferência integral, integração de utilizadores/empresas, concessão de permissões ou activação de módulos comerciais. O SGF não recebe contratações de pessoas para terceiros.
Após melhorias neste pacote, publicar pelo mecanismo `purchasing-release.php`. O manifesto regista versões e hashes; o SGF confere e instala os mesmos bytes. Nunca editar uma versão distribuída no SGF: corrigir a origem e publicar uma versão seguinte.
Percurso verificado no desenvolvimento: `composer purchasing:transfer -- <instalação-SGF> <canal-de-versões>`. Executa testes na origem, prova a fotografia candidata, publica uma nova versão apenas se houver alterações e usa o PHP fixado pelo SGF para conferir a candidata antes da activação. Repetir sem melhorias não reinstala código. Clientes recebem o ZIP e a referência SHA-256 por canal de distribuição de confiança e não precisam do checkout de Compras.
Este contrato não executa migrações nem substitui dependências, dados, acessos ou configuração empresarial. Pacotes que peçam alterações de base de dados são recusados até existir um contrato de migração ensaiado. Versões de código anteriores permanecem disponíveis para reversão explícita. A activação de módulos adquiridos e as permissões continuam sob a configuração e os mecanismos de acesso da instalação.
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/ReplenishmentProjection.php';
+22
View File
@@ -0,0 +1,22 @@
{
"schema": 1,
"package": "tempo/purchasing",
"version": "0.1.1",
"source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250",
"module": {
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": [
"inventory.replenishment_projection"
],
"database_changes": false,
"commercial_permissions": false
},
"files": {
"README.md": "44b6358d36e12fb9db7f897b7223a347b0fea1f98aa4cf5955245671ae171c81",
"bootstrap.php": "829c9f55ea850e64203927302a8daa1ebee791fe2d7763383b2b1a69cfe5f3f5",
"module.json": "f70d4553eec85f6f1f6f50d6c3e7a22678649bfb17248b45342787c405eb3752",
"src/ReplenishmentProjection.php": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": ["inventory.replenishment_projection"],
"database_changes": false,
"commercial_permissions": false
}
@@ -0,0 +1,115 @@
<?php
namespace Tempo\Purchasing\Supply;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Carbon\CarbonImmutable;
/** Quantities use integer thousandths, consistently with the existing stock ledger. */
final class ReplenishmentProjection
{
/** @param array<string,mixed> $policy
* @param list<array{date:string,quantity:int,additional?:bool}> $reservations
* @param list<array{date:string,quantity:int,confirmed:bool}> $receipts
* @param list<array{date:string,quantity:int}> $fulfilledNeeds
* @param list<array{quantity:int,last_usable_on:?string}>|null $lotBuckets
* @return array<string,mixed>
*/
public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array
{
$day = CarbonImmutable::parse($start)->startOfDay();
$daily = $policy['daily_milli'] ?? null;
$rate = $policy['daily_rate'] ?? null;
if ($rate !== null) {
if (! is_int($rate['numerator_milli']) || $rate['numerator_milli'] < 0 || ! is_int($rate['denominator_days']) || $rate['denominator_days'] < 1) {
throw new \InvalidArgumentException('A média histórica exige numerador não negativo e denominador positivo.');
}
$daily = 0;
}
if ($daily === null && $reservations === []) {
return ['available' => false, 'reason' => 'Previsão indisponível: configure consumo manual ou histórico validado.'];
}
$knownOnly = $daily === null;
$daily ??= 0;
$balance = $physical;
$risk = null;
$shortage = null;
$before = null;
$series = [];
$safety = $policy['safety_milli'];
if ($balance <= $safety) {
$risk = $day->toDateString();
$before = $balance;
}
for ($offset = 0; $offset < $policy['horizon_days']; $offset++) {
$date = $day->addDays($offset)->toDateString();
$expired = 0;
if ($lotBuckets !== null) {
foreach ($lotBuckets as &$bucket) {
if ($bucket['last_usable_on'] !== null && $bucket['last_usable_on'] < $date) {
$expired += $bucket['quantity'];
$bucket['quantity'] = 0;
}
}
unset($bucket);
}
$incoming = array_sum(array_column(array_filter($receipts, fn ($r) => $r['confirmed'] && $r['date'] === $date), 'quantity'));
$known = array_filter($reservations, fn ($r) => $r['date'] === $date);
$substitutes = array_sum(array_column(array_filter($known, fn ($r) => ! ($r['additional'] ?? false)), 'quantity'));
$additional = array_sum(array_column(array_filter($known, fn ($r) => $r['additional'] ?? false), 'quantity'));
$satisfied = array_sum(array_column(array_filter($fulfilledNeeds, fn ($n) => $n['date'] === $date), 'quantity'));
$expected = $daily;
if ($rate !== null) {
$cumulative = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset + 1)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$previous = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$expected = $cumulative - $previous;
}
$residualForecast = max(0, $expected - $satisfied - ($offset === 0 ? $consumedToday : 0));
$demand = max($residualForecast, $substitutes) + $additional;
$balance += $incoming - $demand - $expired;
if ($lotBuckets !== null) {
$remaining = $demand;
foreach ($lotBuckets as &$bucket) {
$take = min($remaining, $bucket['quantity']);
$bucket['quantity'] -= $take;
$remaining -= $take;
}
unset($bucket);
}
if ($risk === null && $balance <= $safety) {
$risk = $date;
$before = $balance;
}
if ($shortage === null && $balance < 0) {
$shortage = $date;
}
$series[] = ['date' => $date, 'balance_milli' => $balance, 'incoming_milli' => $incoming, 'demand_milli' => $demand, ...($lotBuckets !== null ? ['expired_milli' => $expired] : [])];
}
$base = $before === null ? 0 : max(0, $policy['target_milli'] - $before);
$quantity = $base === 0 ? 0 : max($base, $policy['minimum_milli']);
$multiple = $policy['pack_milli'];
$quantity = $quantity === 0 ? 0 : intdiv($quantity + $multiple - 1, $multiple) * $multiple;
$excess = $before === null ? 0 : max(0, $before + $quantity - $policy['maximum_milli']);
$futureExcess = 0;
if ($risk !== null && $quantity > 0) {
foreach ($series as $point) {
if ($point['date'] >= $risk) {
$futureExcess = max($futureExcess, $point['balance_milli'] + $quantity - $policy['maximum_milli']);
}
}
}
$mean = $rate === null ? $daily : (string) BigDecimal::of($rate['numerator_milli'])->dividedBy($rate['denominator_days'], 6, RoundingMode::HalfUp);
$reorder = $rate === null ? $daily * $policy['lead_days'] + $safety : BigDecimal::of($rate['numerator_milli'])->multipliedBy($policy['lead_days'])->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->plus($safety)->toInt();
return ['available' => true, 'method' => $knownOnly ? 'known_needs_only' : ($rate === null ? 'manual_calendar_days' : 'historical_mean_calendar_days'), 'demand_complete' => ! $knownOnly, 'physical_milli' => $physical,
'daily_milli' => $mean, 'reorder_point_milli' => $reorder,
'risk_date' => $risk, 'shortage_date' => $shortage,
'order_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'])->toDateString(),
'request_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'] + $policy['approval_days'])->toDateString(),
'base_milli' => $base, 'quantity_milli' => $quantity, 'excess_milli' => $excess,
'future_excess_milli' => $futureExcess, 'requires_exception' => $excess > 0 || $futureExcess > 0, 'series' => $series,
'convention' => 'Stock inicial na abertura; entradas confirmadas de manhã; consumo e nova reposição no fim do dia.'];
}
}
+49
View File
@@ -0,0 +1,49 @@
// SGF host wrapper only; the shared purchasing package remains unchanged.
document.addEventListener('DOMContentLoaded', () => {
const dirty = new Set();
let pending=0;
for(const form of document.querySelectorAll('form[data-action]')){
const submit=form.onsubmit;
if(typeof submit!=='function')continue;
form.onsubmit=async e=>{
pending++;
try{await submit.call(form,e);if(!document.querySelector('#error')?.textContent.trim())dirty.delete(form);}
finally{pending--;}
};
}
document.addEventListener('input', e => { const form=e.target.closest('form');if(form)dirty.add(form); });
window.addEventListener('beforeunload', e => { if(dirty.size||pending){e.preventDefault();e.returnValue='';} });
const back=document.querySelector('[data-sgf-module-back]');
back?.addEventListener('click', e => {
if(pending){e.preventDefault();document.querySelector('#error').textContent='Aguarde a conclusão da operação antes de sair.';return;}
if(!dirty.size)return;
e.preventDefault();
const dialog=document.createElement('dialog');
dialog.setAttribute('aria-label','Alterações por guardar');
dialog.innerHTML='<h2>Alterações por guardar</h2><p>Guarde, descarte ou cancele a saída deste percurso.</p><p role="alert"></p><button data-save>Guardar e continuar</button> <button data-discard>Descartar e continuar</button> <button data-cancel>Cancelar</button>';
document.body.append(dialog);dialog.showModal();
const close=()=>{dialog.close();dialog.remove();back.focus();};
dialog.querySelector('[data-cancel]').onclick=close;
dialog.addEventListener('cancel',e=>{e.preventDefault();close();});
dialog.querySelector('[data-discard]').onclick=()=>{dirty.clear();location.assign(back.href);};
dialog.querySelector('[data-save]').onclick=async()=>{
for(const b of dialog.querySelectorAll('button'))b.disabled=true;
try{
if(dirty.size>1)throw Error('Guarde cada formulário no percurso original antes de sair. O contexto foi mantido.');
for(const form of [...dirty]){
if(!form.isConnected){dirty.delete(form);continue;}
if(!form.reportValidity())throw Error('Corrija os campos. O contexto foi mantido.');
if(typeof form.onsubmit!=='function')throw Error('Guarde no formulário original antes de sair.');
// Existing package handlers validate, post and report errors in #error.
// Successful handlers reload their own page; this destination supersedes
// that reload only after the original promise has completed successfully.
await form.onsubmit({preventDefault(){}});
if(document.querySelector('#error')?.textContent.trim())throw Error('A gravação falhou. O contexto foi mantido.');
dirty.delete(form);
}
location.assign(back.href);
}catch(error){dialog.querySelector('[role=alert]').textContent=error.message;}
finally{for(const b of dialog.querySelectorAll('button'))b.disabled=false;}
};
});
});
+10
View File
@@ -48,3 +48,13 @@ th{background:#f8fafc;color:#475569}th,td{border-color:var(--tempo-line);line-he
.login-layout>aside p{color:#cbd5e1}.login-layout>aside small{color:#94a3b8} .login-layout>aside p{color:#cbd5e1}.login-layout>aside small{color:#94a3b8}
.login-layout>aside .brand{color:white}.login-card .eyebrow{color:var(--tempo-accent-strong)} .login-layout>aside .brand{color:white}.login-card .eyebrow{color:var(--tempo-accent-strong)}
@media(prefers-reduced-motion:reduce){button{transition:none}} @media(prefers-reduced-motion:reduce){button{transition:none}}
/* Modular shell: general options and the active module have separate landmarks. */
.module-navigation{position:sticky;top:0;z-index:30;background:#fff;border-bottom:1px solid #d9e1e8}
.module-topbar,.module-menu{display:flex;align-items:center;gap:1rem;padding:.7rem 1.5rem;flex-wrap:wrap}
.module-topbar{justify-content:space-between}.module-topbar nav{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.module-topbar label{display:flex;align-items:center;gap:.7rem;margin:0}.module-topbar select{min-width:180px}
.module-menu{background:#f0f4f7}.module-menu>strong{color:#164f4b}.module-menu details{position:relative}
.module-menu summary{cursor:pointer;padding:.5rem}.module-menu details>div{position:absolute;top:100%;left:0;background:#fff;box-shadow:0 6px 24px #1232;min-width:240px;max-height:65vh;overflow:auto;padding:.5rem}
.module-menu details button{display:block;width:100%;text-align:left;margin:.2rem 0}.module-menu [aria-current=page]{background:#e1efed;font-weight:700}
.context-transition-backdrop{position:fixed;inset:0;background:#10233299;z-index:100;display:grid;place-items:center;padding:1rem}.context-transition-dialog{max-width:580px;max-height:90vh;overflow:auto}
@media(max-width:700px){.module-topbar,.module-menu{padding:.6rem}.module-topbar nav{width:100%}.module-menu details>div{position:fixed;left:1rem;right:1rem;top:auto;min-width:0}.module-topbar .topbar-account{display:flex}}
+19
View File
@@ -0,0 +1,19 @@
import React,{useEffect,useRef} from 'react';
export default function ContextTransitionDialog({busy,error,onSave,onDiscard,onCancel}) {
const root=useRef(null);
useEffect(()=>{
const previous=document.activeElement;
root.current?.querySelector('button')?.focus();
return ()=>previous?.isConnected&&previous.focus();
},[]);
function keyboard(e){
if(e.key==='Escape'&&!busy){e.preventDefault();onCancel();}
if(e.key!=='Tab')return;
const controls=[...root.current.querySelectorAll('button:not(:disabled)')];
if(!controls.length){e.preventDefault();return;}
if(e.shiftKey&&document.activeElement===controls[0]){e.preventDefault();controls.at(-1).focus();}
else if(!e.shiftKey&&document.activeElement===controls.at(-1)){e.preventDefault();controls[0].focus();}
}
return <div className="context-transition-backdrop"><section ref={root} className="panel context-transition-dialog" role="dialog" aria-modal="true" aria-labelledby="context-transition-title" onKeyDown={keyboard}><h2 id="context-transition-title">Alterações por guardar</h2><p>Guarde as alterações, descarte-as ou cancele a mudança de contexto. Guardar usa o formulário e as validações actuais.</p>{error&&<p role="alert">{error}</p>}<div className="actions"><button disabled={busy} onClick={onSave}>Guardar e continuar</button><button disabled={busy} onClick={onDiscard}>Descartar e continuar</button><button disabled={busy} onClick={onCancel}>Cancelar</button></div></section></div>;
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
const sections = {
commercial:['services','billing','billing_support'], purchasing:['purchasing'], stock:['stock'],
workshop:['workshop'], finance:['receivables','costs'], accounting:['accounting'],
};
export function moduleGroups(groups, module) {
if(!sections[module])return [];
const own=groups.filter(g => sections[module].includes(g.id));
if(module==='commercial')return own;
const shared=groups.flatMap(g=>g.items).filter(i=>['clients','services','service_drafts','billing_drafts'].includes(i.id));
return shared.length?[...own,{id:'shared_records',label:'Cadastros e rascunhos do módulo',items:shared}]:own;
}
export default function ModuleNavigation({groups, context, page, navigate, changeModule, user, busy, logout}) {
const active=context?.modules?.find(m=>m.id===context.module_id);
const own=moduleGroups(groups,active?.id);
return <div className="module-navigation">
<div className="module-topbar"><button className="topbar-brand" onClick={()=>navigate('home')}>TEMPO <span>SGF</span></button>
<nav aria-label="Menu principal"><label>Módulo activo<select aria-label="Módulo activo" value={active?.id||''} disabled={busy} onChange={e=>changeModule(e.target.value||null)}><option value="">Escolher módulo</option>{context?.modules?.map(m=><option key={m.id} value={m.id}>{m.label}{m.development?' · desenvolvimento':''}</option>)}</select></label></nav>
<nav aria-label="Opções gerais"><button aria-current={page==='home'?'page':undefined} onClick={()=>navigate('home')}>Início</button>{groups.find(g=>g.id==='admin')?.items.map(i=><button key={i.id} aria-current={page===i.id?'page':undefined} onClick={()=>navigate(i.id)}>{i.label}</button>)}</nav>
<div className="topbar-account"><strong>{user.name}</strong><button disabled={busy} onClick={logout}>Sair</button></div>
</div>
<nav className="module-menu" aria-label={active?`Navegação de ${active.label}`:'Navegação do módulo'}><strong>{active?.label||'Nenhum módulo seleccionado'}</strong>{own.map(g=><details key={g.id}><summary>{g.label}</summary><div>{g.items.map(i=><button key={i.id} aria-current={page===i.id?'page':undefined} onClick={()=>navigate(i.id)}>{i.label}{i.informational?' · Em preparação':''}</button>)}</div></details>)}{active&&!own.length&&<span>Sem funcionalidades autorizadas neste contexto.</span>}</nav>
</div>;
}
+13
View File
@@ -0,0 +1,13 @@
import React,{useEffect,useState} from 'react';
const labels={clients:'Clientes',services:'Serviços',service_drafts:'Prestações em rascunho',billing_drafts:'Rascunhos de facturação'};
export default function ModuleRecordsWorkspace({context,can,api}) {
const [data,setData]=useState(null),[error,setError]=useState(''),[busy,setBusy]=useState(false);
async function load(){setData(await api('/api/module-records'));}
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
async function associate(e,area,id,version){
e.preventDefault();const form=e.currentTarget,values=Object.fromEntries(new FormData(form));setBusy(true);setError('');
try{await api('/api/module-records','POST',{area,id,version,...values});await load();form.reset();}catch(e){setError(e.message);}finally{setBusy(false);}
}
return <section className="panel"><h1>Associações de módulos</h1><p>Configuração geral da empresa, sujeita aos direitos de consulta originais. Esta vista permite localizar os dados existentes que ainda não têm associação. Não é uma consulta operacional do módulo activo.</p><p>A associação e a sua retirada geram eventos novos e auditados; o original permanece. Não transfere responsabilidade financeira, não cria uma cópia e não concede acesso. Para associar são necessários gestão de acessos, consulta e edição do registo, além de acesso ao módulo de destino.</p>{error&&<p role="alert">{error}</p>}{!data?<p>A carregar…</p>:<>{Object.entries(data.records).map(([area,records])=><section key={area}><h2>{labels[area]}</h2>{!records.length&&<p>Sem registos neste âmbito autorizado.</p>}{records.map(r=><article className="panel" key={r.id}><strong>#{r.id} · {r.name} · versão {r.version}</strong><p>{r.modules.length?`Associado a: ${r.modules.join(', ')}`:'Sem associação a módulo — pendente de conferência explícita.'}</p>{can('access.manage')&&can(`${area}.edit`,area.endsWith('_drafts')?context.unit?.id??null:null)&&<form onSubmit={e=>associate(e,area,r.id,r.version)}><label>Operação<select name="operation"><option value="associate">Associar</option><option value="remove">Retirar associação por novo evento</option></select></label><label>Módulo de destino<select name="module_id" required defaultValue=""><option value="">Escolher módulo autorizado</option>{data.modules.map(m=><option key={m.id} value={m.id}>{m.label}</option>)}</select></label><label>Motivo e fundamento da associação<input name="reason" required minLength={3} maxLength={500}/></label><button disabled={busy}>Registar decisão explícita</button></form>}</article>)}</section>)}</>}</section>;
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function navigationGroups(can, unit) {
{id:'workshop',label:'Oficinas',items:[...visibleWorkshopSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true})),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento comercial','workshop_corrections.view',unit)]}, {id:'workshop',label:'Oficinas',items:[...visibleWorkshopSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true})),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento comercial','workshop_corrections.view',unit)]},
{id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]}, {id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]},
{id:'accounting',label:'Contabilidade e fiscalidade',items:[...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]}, {id:'accounting',label:'Contabilidade e fiscalidade',items:[...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('access','Acessos','access.manage')]}, {id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('module_records','Associações de módulos','access.manage'),item('access','Acessos','access.manage')]},
].map(g=>({...g,items:g.items.filter(i=>i.visible)})).filter(g=>g.items.length); ].map(g=>({...g,items:g.items.filter(i=>i.visible)})).filter(g=>g.items.length);
} }
+40 -9
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '../css/app.css'; import '../css/app.css';
import '../css/tempo-theme.css'; import '../css/tempo-theme.css';
@@ -14,7 +14,11 @@ import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx'; import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
import PurchasingBudgetWorkspace from './PurchasingBudgetWorkspace.jsx'; import PurchasingBudgetWorkspace from './PurchasingBudgetWorkspace.jsx';
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx'; import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.jsx'; import {navigationGroups} from './WorkspaceNavigation.jsx';
import ModuleNavigation, {moduleGroups} from './ModuleNavigation.jsx';
import {ContextTransition} from './context-transition.js';
import ModuleRecordsWorkspace from './ModuleRecordsWorkspace.jsx';
import ContextTransitionDialog from './ContextTransitionDialog.jsx';
import WorkspaceHome from './WorkspaceHome.jsx'; import WorkspaceHome from './WorkspaceHome.jsx';
import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx'; import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx';
import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.jsx'; import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.jsx';
@@ -37,22 +41,47 @@ function App() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [page, setPage] = useState('home'); const [page, setPage] = useState('home');
const [aSelection, setASelection] = useState(null); const [aSelection, setASelection] = useState(null);
const transitions = useRef(new ContextTransition());
const [pendingTransition, setPendingTransition] = useState(null);
const [transitionBusy, setTransitionBusy] = useState(false);
const context = state?.context; const context = state?.context;
const currentToken = useRef(context?.token); currentToken.current = context?.token;
const mountedToken = useRef(context?.token);
useEffect(() => { if(mountedToken.current !== context?.token) { transitions.current.discard(); setASelection(null); setPage('home'); mountedToken.current=context?.token; } }, [context?.token]);
useEffect(() => { const leave=e=>{transitions.current.prune(); if(transitions.current.dirty.size||transitions.current.pending){e.preventDefault();e.returnValue='';}}; window.addEventListener('beforeunload',leave); return ()=>window.removeEventListener('beforeunload',leave); }, []);
function transition(action) { transitions.current.prune(); if(transitions.current.pending||busy||transitionBusy) {setError('Aguarde a conclusão da operação antes de mudar de contexto.');return;} if(transitions.current.dirty.size) setPendingTransition(()=>action); else action(); }
async function resolveTransition(save) { setTransitionBusy(true);setError(''); try { if(save) await transitions.current.save(); else transitions.current.discard(); const action=pendingTransition;setPendingTransition(null); await action(); } catch(e){setError(e.message);} finally{setTransitionBusy(false);} }
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit); const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
const groups = navigationGroups(can, context?.unit?.id ?? null); const allGroups = navigationGroups(can, context?.unit?.id ?? null);
const navigate = (id,selection=null) => { setASelection(selection); setPage(id); setError(''); }; const groups = [...allGroups.filter(g=>['start','admin'].includes(g.id)), ...moduleGroups(allGroups,context?.module_id)];
const navigate = (id,selection=null) => transition(async () => {
setError('');
if(!groups.some(g=>g.items.some(i=>i.id===id))) {
const target=context?.modules?.find(m=>moduleGroups(allGroups,m.id).some(g=>g.items.some(i=>i.id===id)));
if(!target){setError('Destino sem módulo ou permissão autorizados.');return;}
try {
const next=await api('/api/context','POST',{tenant_id:context.company.tenant_id,company_id:context.company.id,unit_id:context.unit?.id??null,module_id:target.id});
mountedToken.current=next.context.token;currentToken.current=next.context.token;setState(next);
} catch(e){setError(e.message);return;}
}
transitions.current.discard();setASelection(selection);setPage(id);
});
useEffect(() => { if (!groups.some(g => g.items.some(i => i.id === page))) setPage('home'); }, [context?.token, JSON.stringify(context?.grants), page]); useEffect(() => { if (!groups.some(g => g.items.some(i => i.id === page))) setPage('home'); }, [context?.token, JSON.stringify(context?.grants), page]);
async function api(path, method = 'GET', body, token = context?.token, commandKey = null) { async function api(path, method = 'GET', body, token = context?.token, commandKey = null) {
const execute = async () => {
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '', ...(commandKey ? { 'Idempotency-Key': commandKey } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '', ...(commandKey ? { 'Idempotency-Key': commandKey } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { await refresh(); } if ([401,403,409].includes(response.status)) { await refresh(); }
const message = response.status === 419 ? 'A sessão expirou. Actualize a página e entre novamente.' : Object.values(result.errors || {}).flat().join(' ') || result.message || 'Não foi possível concluir a operação.'; const message = response.status === 419 ? 'A sessão expirou. Actualize a página e entre novamente.' : Object.values(result.errors || {}).flat().join(' ') || result.message || 'Não foi possível concluir a operação.';
throw new Error(message); throw new Error(message);
} }
if(token && token !== currentToken.current) throw new Error('Resposta de um contexto anterior ignorada.');
return result; return result;
};
return method==='GET' ? execute() : transitions.current.command(execute);
} }
async function refresh() { async function refresh() {
const response = await fetch('/api/session', { credentials: 'same-origin', headers: { Accept: 'application/json' } }); const response = await fetch('/api/session', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
@@ -70,21 +99,23 @@ function App() {
async function select(companyId, unitId = null) { async function select(companyId, unitId = null) {
const company = state.companies.find(c => c.id === Number(companyId)); const company = state.companies.find(c => c.id === Number(companyId));
if (!company) return; if (!company) return;
await perform(() => api('/api/context', 'POST', { tenant_id: company.tenant_id, company_id: company.id, unit_id: unitId }), 'Contexto alterado. Os formulários foram recarregados.'); transition(() => perform(() => api('/api/context', 'POST', { tenant_id: company.tenant_id, company_id: company.id, unit_id: unitId }), 'Contexto alterado. Seleccione o módulo.'));
} }
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>; if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>; if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>; if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><WorkspaceNavigation key={context?.token ?? "no-context"} groups={groups} page={page} navigate={navigate} user={state.user} busy={busy} logout={() => perform(() => api("/api/logout", "POST"), "Sessão terminada.")} /><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></header><main> return <div className="shell" onSubmitCapture={e=>{transitions.current.submitting=e.target;}} onInputCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onChangeCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onClickCapture={e=>{transitions.current.submitting=e.target.closest('form')||e.target.closest('section');const a=e.target.closest('a[href]');if(a&&a.origin===location.origin){e.preventDefault();transition(()=>{location.href=a.href;});}}}><ModuleNavigation groups={allGroups} context={context} page={page} navigate={navigate} changeModule={module_id=>transition(async()=>{await perform(()=>api('/api/context','POST',{tenant_id:context.company.tenant_id,company_id:context.company.id,unit_id:context.unit?.id??null,module_id}),'Módulo alterado.');})} user={state.user} busy={busy||transitionBusy} logout={()=>transition(()=>perform(()=>api('/api/logout','POST'),'Sessão terminada.'))} />{pendingTransition&&<ContextTransitionDialog busy={transitionBusy} error={error} onSave={()=>resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}<div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>} {error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section> <section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}> {!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={`${context.token}:${page}`}>
{page !== 'home' && page !== 'module_records' && context.module_id && <p className="help" role="status">Cadastros e rascunhos: apenas associações explícitas ao módulo activo. Dados anteriores sem associação estão preservados e pendentes de conferência pelo responsável. {can('access.manage')&&<button onClick={()=>navigate('module_records')}>Associações de módulos</button>}</p>}
{page === 'home' && <WorkspaceHome context={context} />} {page === 'home' && <WorkspaceHome context={context} />}
{page === 'module_records' && <ModuleRecordsWorkspace context={context} can={can} api={api}/>}
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} />} {page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'workshop_corrections' && <WorkshopCorrectionsWorkspace context={context} can={can} api={api} busy={busy} />} {page === 'workshop_corrections' && <WorkshopCorrectionsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>} {page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>} {page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />} {serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => navigate(area,{area,id})} />}
{billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&<BillingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null,create=false)=>navigate(area,{area,id,create})}/>} {billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&<BillingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null,create=false)=>navigate(area,{area,id,create})}/>}
{stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>} {stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
{accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>} {accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
+32
View File
@@ -0,0 +1,32 @@
// A single coordinator for page, module, company and unit transitions.
// Saving uses the existing form and its existing command/validation path.
export class ContextTransition {
constructor() { this.dirty = new Set(); this.pending = 0; this.saved = 0; this.failed = 0; this.versions = new WeakMap(); this.submitting = null; }
changed(form) { if (form) { this.dirty.add(form); this.versions.set(form,(this.versions.get(form)||0)+1); } }
prune() { for (const form of this.dirty) if (!form.isConnected) this.dirty.delete(form); }
async command(operation) {
this.pending++;
const form=this.submitting, version=form&&this.versions.get(form);
try { const result = await operation(); this.saved++; if(form&&version===this.versions.get(form))this.dirty.delete(form); return result; }
catch (e) { this.failed++; throw e; }
finally { this.pending--; }
}
async save() {
this.prune();
for (const form of [...this.dirty]) {
if (!form.requestSubmit) throw new Error('Este editor exige gravação explícita no próprio percurso. Cancele a mudança, guarde e tente novamente.');
if (!form.reportValidity()) throw new Error('Corrija os campos antes de mudar de contexto.');
const saved = this.saved, failed = this.failed;
const version=this.versions.get(form);
this.submitting=form;
form.requestSubmit();
// React handlers start commands synchronously or after a short validation step.
await new Promise(resolve => setTimeout(resolve, 100));
while (this.pending) await new Promise(resolve => setTimeout(resolve, 40));
if (this.failed !== failed || this.saved === saved) throw new Error('Não foi possível guardar. O contexto foi mantido.');
if(version!==this.versions.get(form))throw new Error('Existem alterações posteriores à gravação. O contexto foi mantido.');
this.dirty.delete(form);
}
}
discard() { this.dirty.clear(); }
}
+3 -1
View File
@@ -27,10 +27,12 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/logout', [IdentityController::class, 'logout']); Route::post('/api/logout', [IdentityController::class, 'logout']);
Route::post('/api/context', [IdentityController::class, 'select']); Route::post('/api/context', [IdentityController::class, 'select']);
Route::middleware(RequireCompanyContext::class)->group(function (): void { Route::middleware(RequireCompanyContext::class)->group(function (): void {
Route::get('/api/module-records', [\App\Modules\Identity\Http\ModuleRecordsController::class,'index']);
Route::post('/api/module-records', [\App\Modules\Identity\Http\ModuleRecordsController::class,'associate']);
Route::get('/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'page']); Route::get('/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'page']);
Route::get('/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'page']); Route::get('/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'page']);
Route::get('/api/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'index']); Route::get('/api/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'index']);
Route::post('/api/supplier-payments/{action}', [\App\Modules\Purchasing\SupplierPaymentsController::class,'command'])->whereIn('action',['payment-prepare','payment-decide','allocation-prepare','allocation-decide']); Route::post('/api/supplier-payments/{action}', [\App\Modules\Purchasing\SupplierPaymentsController::class,'command'])->whereIn('action',['payment-prepare','payment-decide','allocation-prepare','allocation-decide','reversal-prepare','reversal-confirm']);
Route::get('/api/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'index']); Route::get('/api/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'index']);
Route::post('/api/supplier-payables/{action}', [\App\Modules\Purchasing\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate']); Route::post('/api/supplier-payables/{action}', [\App\Modules\Purchasing\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate']);
Route::get('/api/purchasing-budget', [\App\Modules\Purchasing\BudgetController::class, 'index']); Route::get('/api/purchasing-budget', [\App\Modules\Purchasing\BudgetController::class, 'index']);
+31
View File
@@ -0,0 +1,31 @@
// Separate development database/role/app: regression resets cannot erase it.
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
const pointer='C:/laragon/data/sgf-tempo/run/modular-regression-current.json';
const state=JSON.parse(fs.readFileSync(pointer,'utf8'));
if(!/^[a-f0-9]{12}$/.test(state.id)||path.basename(state.root)!=='modular-regression-'+state.id||state.port!==55439)throw Error('Unexpected isolated host');
if(state.development)throw Error('Development host already configured; preserve it.');
const database='sgf_modules_dev_'+state.id,role='sgf_modules_qa_'+state.id,password=crypto.randomBytes(30).toString('hex');
const app=path.join(state.root,'development-app'),pg='C:/Program Files/PostgreSQL/17/bin';
const admin=fs.readFileSync(path.join(state.root,'private/admin-password.txt'),'utf8');
function run(exe,args,options={}){const r=spawnSync(exe,args,{encoding:'utf8',windowsHide:true,maxBuffer:8*1024*1024,...options});if(r.status!==0)throw Error((r.stdout||'')+(r.stderr||''));return r.stdout;}
const identity=run(path.join(pg,'psql.exe'),['-h','127.0.0.1','-p',String(state.port),'-U','sgf_regression_owner','-d','postgres','-At','-c',"SELECT current_setting('cluster_name')"],{env:{...process.env,PGPASSWORD:admin}}).trim();
if(identity!=='sgf_modular_regression_'+state.id)throw Error('Unexpected database cluster');
fs.cpSync(state.app,app,{recursive:true,filter:p=>!['storage','.env','.env.testing','.env.browser-testing'].includes(path.relative(state.app,p).split(path.sep)[0])&&!p.includes(path.join('bootstrap','cache')+path.sep)});
for(const dir of ['storage/framework/cache/data','storage/framework/sessions','storage/framework/views','storage/logs','bootstrap/cache'])fs.mkdirSync(path.join(app,dir),{recursive:true});
if(fs.existsSync(path.join(app,'public/hot')))fs.unlinkSync(path.join(app,'public/hot'));
function files(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(path.join(dir,e.name)):[path.join(dir,e.name)]);}
const adaptations=[];
for(const dir of ['app','database','config','tests','scripts'])for(const p of files(path.join(app,dir)).filter(p=>/\.(php|sql|mjs)$/.test(p))){
const text=fs.readFileSync(p,'utf8'),next=text.replaceAll('sgf_tempo_test',database).replaceAll('sgf_test_runner',role);
if(next!==text){fs.writeFileSync(p,next);adaptations.push(path.relative(app,p));}
}
fs.writeFileSync(path.join(state.root,'development-identity-adaptations.json'),JSON.stringify(adaptations,null,2));
run(path.join(pg,'psql.exe'),['-h','127.0.0.1','-p',String(state.port),'-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1'],{env:{...process.env,PGPASSWORD:admin},input:`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;\nCREATE DATABASE ${database} OWNER ${role};\nREVOKE ALL ON DATABASE ${database} FROM PUBLIC;\n`});
const env=`APP_NAME=SGF-Modular-Development\nAPP_KEY=base64:${crypto.randomBytes(32).toString('base64')}\nAPP_DEBUG=false\nAPP_URL=http://127.0.0.1:${state.web_port}\nAPP_ENV=testing\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=${state.port}\nDB_DATABASE=${database}\nDB_USERNAME=${role}\nDB_PASSWORD=${password}\nDB_URL=\nSESSION_DRIVER=array\nCACHE_STORE=array\nBCRYPT_ROUNDS=4\n`;
fs.writeFileSync(path.join(app,'.env.testing'),env);
fs.writeFileSync(path.join(app,'.env'),env.replace('APP_ENV=testing','APP_ENV=local'));
run('php',['artisan','migrate','--env=testing','--force','--no-interaction'],{cwd:app});
const provision=run('php',['scripts/modular-development-profiles.php',path.join(state.root,'private/development.json')],{cwd:app});
state.development={app,database,role,port:state.port,url:`http://127.0.0.1:${state.web_port}`};
fs.writeFileSync(pointer,JSON.stringify(state,null,2));fs.writeFileSync(path.join(state.root,'development-provision.json'),provision);
console.log(JSON.stringify({configured:true,...state.development,credentials_printed:false}));
@@ -0,0 +1,39 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8'));
const privateData=JSON.parse(fs.readFileSync(state.root+'/private/development.json','utf8'));
const base=`http://127.0.0.1:${state.web_port}`;
const proof={synthetic_only:true,host:base,database_port:state.port,profiles:[],pending_associations_unchanged:true,visual_review:false};
for(const profile of privateData.profiles){
const jar=new Map();let csrf='',token='';
async function api(url,method='GET',body,status=200){
const r=await fetch(base+url,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':token,'Idempotency-Key':crypto.randomUUID()},...(body===undefined?{}:{body:JSON.stringify(body)})});
for(const cookie of r.headers.getSetCookie()){const [pair]=cookie.split(';');const at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}
const data=await r.json();assert.equal(r.status,status,`${profile.role} ${method} ${url}: ${data.message||'unexpected status'}`);
if(data.csrf)csrf=data.csrf;if(data.context?.token)token=data.context.token;
return data;
}
await api('/api/session');await api('/api/login','POST',{email:profile.email,password:privateData.password});
const select=module_id=>api('/api/context','POST',{tenant_id:privateData.tenant_id,company_id:privateData.company_id,unit_id:privateData.unit_id,module_id});
const session=await select('commercial');
assert.equal(session.context.modules.length,6);assert(session.context.modules.every(m=>m.development));
const has=p=>session.context.grants.some(g=>g.permission===p);
const roleProof={role:profile.role,modules:session.context.modules.map(m=>m.id),prepare:has('direct_costs.prepare'),validate:has('direct_costs.validate'),access_manage:has('access.manage')};
assert.equal(roleProof.prepare,profile.role==='preparador');assert.equal(roleProof.validate,profile.role==='revisor');assert.equal(roleProof.access_manage,profile.role==='preparador');
const clients=await api('/api/a/clients');assert.equal(clients.records.length,0);assert(clients.scope_notice);
if(profile.role==='preparador'){
const inventory=await api('/api/module-records');const pending=inventory.records.clients.find(r=>r.id===privateData.pending_record_id);
assert(pending);assert.deepEqual(pending.modules,[]);
}else{
await api('/api/module-records','GET',undefined,403);
await api('/api/a/clients','POST',{name:'Must remain forbidden'},403);
}
for(const module of ['purchasing','stock','workshop','finance','accounting'])await select(module);
await select('purchasing');await api('/api/supplier-payables','GET',undefined,403);
await select('workshop');await api('/api/workshop');
await select('finance');await api('/api/direct-costs');
await select('accounting');await api('/api/catalogue-accounting');
proof.profiles.push(roleProof);await api('/api/logout','POST');
}
fs.writeFileSync(state.root+'/development-http-proof.json',JSON.stringify(proof,null,2));
console.log(JSON.stringify(proof,null,2));
+47
View File
@@ -0,0 +1,47 @@
<?php
// Provision only the new, disposable modular-development host. No client lookup.
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';
$app->loadEnvironmentFrom('.env.testing');
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use App\Modules\Services\ServicePermissions;
App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$identity=DB::selectOne("SELECT current_setting('cluster_name') AS cluster,current_database() AS db,current_user AS role,inet_server_port() AS port");
abort_unless(preg_match('/^sgf_modular_regression_[a-f0-9]{12}$/D',$identity->cluster) && (int)$identity->port===55439 && $identity->db==='sgf_tempo_test' && $identity->role==='sgf_test_runner',403);
$private=$argv[1]??'';
abort_unless(is_dir(dirname($private)) && basename(dirname($private))==='private',403);
$result=DB::transaction(function(){
$f=IdentityFixture::create('-modular-'.bin2hex(random_bytes(5)));
foreach(['manager'=>'Desenvolvimento — Preparador','reader'=>'Desenvolvimento — Revisor','limited'=>'Desenvolvimento — Consulta'] as $who=>$name){$f[$who]->name=$name;$f[$who]->save();}
DB::table('permission_grants')->where('user_id',$f['limited']->id)->where('permission','access.manage')->delete();
App\Modules\Identity\Services\Audit::record('access.revoked',$f['manager']->id,$f['tenant'],$f['company'],subject:$f['limited']->id,
details:['permission'=>'access.manage','reason'=>'Perfil de consulta sintético novo, sem administração de acessos.']);
foreach(['manager','reader','limited'] as $who){
foreach(['clients.view','services.view','issuer_documents.view','client_documents.view','catalogue_accounting.view','service_fiscal.view'] as $p)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],$p);
foreach(['service_drafts.view','billing_drafts.view','draft_documents.view','service_invoices.view','service_receivables.view','workshop.view','workshop_billing.view','workshop_corrections.view','direct_costs.view','industrial_costs.view','industrial_budgets.view','industrial_budget_comparisons.view'] as $p)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],$p,$f['unit']);
}
foreach(ServicePermissions::AREAS as $area)foreach(['create','edit'] as $action)IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],"$area.$action",ServicePermissions::unitScoped($area)?$f['unit']:null);
foreach(['catalogue_accounting.propose','service_fiscal.propose'] as $p)IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],$p);
foreach(['catalogue_accounting.approve','service_fiscal.approve'] as $p)IdentityFixture::grant($f['reader']->id,$f['tenant'],$f['company'],$p);
foreach(['workshop.open','workshop.assign','workshop.authorize','workshop_billing.prepare','workshop_billing.generate','workshop_corrections.request','workshop_corrections.apply','direct_costs.prepare','industrial_costs.prepare','industrial_budgets.prepare','industrial_budget_comparisons.prepare','service_receivables.prepare','service_receivables.allocate','service_receivables.correction_request'] as $p)IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],$p,$f['unit']);
foreach(['workshop_billing.review','workshop_corrections.review','direct_costs.validate','industrial_costs.validate','industrial_budgets.validate','service_receivables.confirm','service_receivables.reconcile','service_receivables.correction_approve'] as $p)IdentityFixture::grant($f['reader']->id,$f['tenant'],$f['company'],$p,$f['unit']);
// Pending example intentionally has no module association.
$pending=DB::table('a_clients')->insertGetId(['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>null,'name'=>'Registo sintético pendente de associação','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]);
foreach(['manager','reader','limited'] as $who)App\Modules\Identity\Services\Audit::record('development.profile.created',$f['manager']->id,$f['tenant'],$f['company'],$f['unit'],$f[$who]->id,details:['reason'=>'Perfil sintético isolado de '.$who.'; descoberta não concede operações.']);
return ['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$f['unit'],'pending_record_id'=>$pending,
'profiles'=>array_map(fn($who)=>['role'=>$who==='manager'?'preparador':($who==='reader'?'revisor':'consulta'),'id'=>$f[$who]->id,'email'=>$f[$who]->email],['manager','reader','limited']),
'password'=>$f['password']];
});
file_put_contents($private,json_encode($result,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
unset($result['password']);
$profiles=implode(',',array_map(fn($p)=>$p['id'].':'.$result['tenant_id'].':'.$result['company_id'],$result['profiles']));
$env=file_get_contents(base_path('.env.testing'));
$env=preg_replace('/^APP_ENV=.*$/m','APP_ENV=browser-testing',$env);
$env=preg_replace('/^SESSION_DRIVER=.*$/m','SESSION_DRIVER=file',$env);
$env.="\nSESSION_COOKIE=sgf_modular_development\nSGF_DEVELOPMENT_PROFILES={$profiles}\n";
file_put_contents(base_path('.env.browser-testing'),$env);
echo json_encode(['configured'=>true,'database_identity'=>(array)$identity,'development'=>$result,'module_count'=>6,'pending_associations_unchanged'=>true],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR).PHP_EOL;
+14
View File
@@ -0,0 +1,14 @@
<?php
// Read-only fingerprints. No identities, credentials or business documents printed.
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
$identity=DB::selectOne('SELECT current_database() AS database,current_user AS role,inet_server_port() AS port');
$tables=[];
foreach(['permission_grants','company_memberships','identity_audit'] as $table){
$r=DB::selectOne("SELECT count(*) AS rows,md5(coalesce(string_agg(row_to_json(t)::text,E'\\n' ORDER BY row_to_json(t)::text),'')) AS fingerprint FROM {$table} t");
$tables[$table]=['rows'=>(int)$r->rows,'fingerprint'=>$r->fingerprint];
}
echo json_encode(['database'=>(array)$identity,'tables'=>$tables,'guard_sha256'=>hash_file('sha256',base_path('app/Shared/Testing/TestingDatabaseGuard.php')),'purchasing_pointer_sha256'=>is_file(base_path('packages/purchasing/current.json'))?hash_file('sha256',base_path('packages/purchasing/current.json')):null],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+74
View File
@@ -0,0 +1,74 @@
// Disposable Windows regression host. Never connects to the installed databases.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {spawnSync} from 'node:child_process';
const source=process.cwd(), runtime='C:/laragon/data/sgf-tempo/run', pg='C:/Program Files/PostgreSQL/17/bin';
const pointer=path.join(runtime,'modular-regression-current.json');
const action=process.argv[2]||'prepare';
function run(exe,args,options={}) {
const r=spawnSync(exe,args,{encoding:'utf8',windowsHide:true,maxBuffer:24*1024*1024,...options});
if(r.status!==0)throw Error(`${path.basename(exe)} failed (${r.status}): ${(r.stdout||'')+(r.stderr||'')}`);
return r.stdout;
}
function files(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(path.join(dir,e.name)):[path.join(dir,e.name)]);}
function sync(state){
for(const dir of ['app','bootstrap','config','database','resources','routes','tests','packages','scripts','public']) {
fs.cpSync(path.join(source,dir),path.join(state.app,dir),{recursive:true,filter:p=>!p.includes('bootstrap'+path.sep+'cache'+path.sep)&&!p.endsWith('.update.lock')});
}
for(const file of ['artisan','composer.json','composer.lock','phpunit.xml','package.json'])fs.copyFileSync(path.join(source,file),path.join(state.app,file));
// The sole staging adaptation to application logic is its isolated loopback
// port. The source guard stays fixed and unchanged at 55432.
const rewrites=[];
for(const dir of ['app','database','tests','scripts'])for(const file of files(path.join(state.app,dir)).filter(p=>/\.(php|sql|ps1|mjs)$/.test(p))){
const original=fs.readFileSync(file,'utf8'),adapted=original.replaceAll('55432',String(state.port));
if(adapted!==original){fs.writeFileSync(file,adapted);rewrites.push(path.relative(state.app,file));}
}
fs.writeFileSync(path.join(state.root,'port-adaptations.json'),JSON.stringify(rewrites,null,2));
}
if(action==='prepare'){
const id=crypto.randomBytes(6).toString('hex'),root=path.join(runtime,'modular-regression-'+id);
const state={id,root,app:path.join(root,'app'),cluster:path.join(root,'postgres'),port:55439,web_port:8039};
fs.mkdirSync(state.app,{recursive:true});
fs.mkdirSync(path.join(root,'private'),{recursive:true});
const admin=crypto.randomBytes(30).toString('hex'),password=crypto.randomBytes(30).toString('hex');
fs.writeFileSync(path.join(root,'private','admin-password.txt'),admin);
fs.writeFileSync(path.join(root,'private','runner-password.txt'),password);
sync(state);
fs.cpSync(path.join(source,'vendor'),path.join(state.app,'vendor'),{recursive:true});
for(const dir of ['storage/framework/cache/data','storage/framework/sessions','storage/framework/views','storage/logs','bootstrap/cache'])fs.mkdirSync(path.join(state.app,dir),{recursive:true});
const common=`APP_NAME=SGF-Modular-QA\nAPP_KEY=base64:${crypto.randomBytes(32).toString('base64')}\nAPP_DEBUG=false\nAPP_URL=http://127.0.0.1:${state.web_port}\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=${state.port}\nDB_DATABASE=sgf_tempo_test\nDB_USERNAME=sgf_test_runner\nDB_PASSWORD=${password}\nDB_URL=\nSESSION_DRIVER=array\nCACHE_STORE=array\n`;
fs.writeFileSync(path.join(state.app,'.env.testing'),common+'APP_ENV=testing\n');
fs.writeFileSync(path.join(state.app,'.env'),common.replace('DB_DATABASE=sgf_tempo_test','DB_DATABASE=sgf_tempo_dev')+'APP_ENV=local\n');
run(path.join(pg,'initdb.exe'),['-D',state.cluster,'-U','sgf_regression_owner','-A','scram-sha-256','--pwfile='+path.join(root,'private','admin-password.txt'),'-E','UTF8','--locale=C']);
fs.appendFileSync(path.join(state.cluster,'postgresql.conf'),`\nlisten_addresses='127.0.0.1'\nport=${state.port}\ncluster_name='sgf_modular_regression_${id}'\n`);
run(path.join(pg,'pg_ctl.exe'),['-D',state.cluster,'-l',path.join(root,'postgres.log'),'start'],{stdio:'ignore'});
run(path.join(pg,'psql.exe'),['-h','127.0.0.1','-p',String(state.port),'-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1'],{
env:{...process.env,PGPASSWORD:admin},input:`CREATE ROLE sgf_test_runner LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
CREATE DATABASE sgf_tempo_test OWNER sgf_test_runner;
REVOKE ALL ON DATABASE sgf_tempo_test FROM PUBLIC;
CREATE DATABASE sgf_tempo_dev;
REVOKE ALL ON DATABASE sgf_tempo_dev FROM PUBLIC;
`});
fs.writeFileSync(pointer,JSON.stringify(state,null,2));
console.log(JSON.stringify({prepared:true,...state,source_guard_unchanged:true,credentials_printed:false}));
}else{
const state=JSON.parse(fs.readFileSync(pointer,'utf8'));
if(path.dirname(path.resolve(state.root))!==path.resolve(runtime)||!/^modular-regression-[a-f0-9]{12}$/.test(path.basename(state.root))
||path.resolve(state.app)!==path.resolve(state.root,'app')||path.resolve(state.cluster)!==path.resolve(state.root,'postgres')||state.port!==55439)throw Error('Unexpected runtime root');
if(action==='sync'){sync(state);console.log('Isolated source refreshed; original databases and guard untouched.');}
else if(action==='test'||action==='focused'){
const start=new Date().toISOString();
const extra=process.argv.slice(3);
const suffix=action==='focused'?'-focused':'';
const selection=action==='focused'?['--group','modular-focused']:['--exclude-group','modular-focused'];
const r=spawnSync('php',['vendor/phpunit/phpunit/phpunit',...selection,...extra,'--log-junit',path.join(state.root,'junit'+suffix+'.xml')],{
cwd:state.app,encoding:'utf8',windowsHide:true,maxBuffer:24*1024*1024,
env:{...process.env,SGF_ISOLATED_REGRESSION_RUN:state.id}});
fs.writeFileSync(path.join(state.root,'regression-output'+suffix+'.txt'),(r.stdout||'')+(r.stderr||''));
console.log(((r.stdout||'')+(r.stderr||'')).split(/\r?\n/).filter(line=>/^(PHPUnit|Runtime:|Configuration:|Time:|OK |Tests:|There (was|were)|[0-9]+\))/.test(line.replace(/\x1b\[[0-9;]*m/g,''))).join('\n'));
fs.writeFileSync(path.join(state.root,'result'+suffix+'.json'),JSON.stringify({started_at:start,finished_at:new Date().toISOString(),exit_code:r.status,port:state.port,source_guard_unchanged:true},null,2));
process.exitCode=r.status||0;
}else throw Error('Use prepare, sync, test or focused');
}
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
import {ContextTransition} from '../resources/js/context-transition.js';
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
try {
const {default:Navigation,moduleGroups}=await server.ssrLoadModule('/resources/js/ModuleNavigation.jsx');
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const groups=navigationGroups(()=>true,7);
assert.deepEqual(moduleGroups(groups,'purchasing').map(g=>g.id),['purchasing','shared_records']);
assert.deepEqual(moduleGroups(groups,'commercial').map(g=>g.id),['services','billing','billing_support']);
assert.equal(moduleGroups(groups,'invalid').length,0);
const html=renderToStaticMarkup(React.createElement(Navigation,{groups,context:{module_id:'workshop',modules:[{id:'workshop',label:'Oficinas'}]},page:'workshop',user:{name:'Synthetic'},navigate(){},changeModule(){},logout(){}}));
assert(html.includes('Módulo activo'));assert(html.includes('Navegação de Oficinas'));assert(html.includes('Opções gerais'));
assert(!html.includes('Todos os documentos'));assert(!html.includes('Obrigações a fornecedores'));
assert(html.includes('aria-current="page"'));assert(!html.includes('consulta@example'));
const t=new ContextTransition();
const form={isConnected:true,reportValidity:()=>true,requestSubmit:()=>{void t.command(async()=>true);}};
t.changed(form);await t.save();assert.equal(t.dirty.size,0);assert.equal(t.saved,1);
const failure={...form,requestSubmit:()=>{void t.command(async()=>{throw Error('save refused');}).catch(()=>{});}};
t.changed(failure);await assert.rejects(()=>t.save(),/contexto foi mantido/);assert(t.dirty.has(failure));
t.discard();assert.equal(t.dirty.size,0);
const invalid={...form,reportValidity:()=>false};t.changed(invalid);await assert.rejects(()=>t.save(),/Corrija/);assert(t.dirty.has(invalid));
t.discard();const editor={isConnected:true};t.changed(editor);await assert.rejects(()=>t.save(),/gravação explícita/);assert(t.dirty.has(editor));
t.discard();t.changed({...form,isConnected:false});t.prune();assert.equal(t.dirty.size,0);
// Editing during an in-flight save cannot be discarded by its late response.
let finish;const concurrent={...form,requestSubmit:()=>{void t.command(()=>new Promise(resolve=>{finish=resolve;}));}};
t.changed(concurrent);const saving=t.save();t.changed(concurrent);finish(true);await assert.rejects(()=>saving,/posteriores/);assert(t.dirty.has(concurrent));
console.log('Module navigation and transitions passed: module separation, active context, save, failed save, invalid form, discard, unsupported editor and edits during save. No browser or database.');
} finally {await server.close();}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Tempo\Distribution\Release;
use Tempo\Purchasing\Supply\ReplenishmentProjection;
// Run this installed copy in the client host; no source checkout or application boot.
$host = dirname(__DIR__);
require $host.'/vendor/autoload.php';
require $host.'/packages/purchasing/bootstrap.php';
$current = Release::current($host);
$policy = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
$result = (new ReplenishmentProjection)->calculate($policy, 120000, '2026-11-01');
if ($result['quantity_milli'] !== 84000) {
throw new RuntimeException('O pacote instalado não preserva o resultado de referência.');
}
$base = strtolower(str_replace('\\', '/', realpath($host))).'/';
foreach (get_included_files() as $file) {
if (! str_starts_with(strtolower(str_replace('\\', '/', realpath($file))), $base)) {
throw new RuntimeException('Foi carregado código fora da instalação cliente.');
}
}
echo json_encode(['version' => $current['version'], 'runtime' => PHP_VERSION, 'all_loaded_code_in_client_host' => true,
'source_checkout_required' => false, 'database_connected' => false, 'quantity_milli' => $result['quantity_milli']], JSON_THROW_ON_ERROR)."\n";
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use Tempo\Distribution\Release;
use Tempo\Purchasing\Economic\ProposalScoring;
use Tempo\Purchasing\Economic\QuotationPricing;
use Tempo\Purchasing\Supply\ReplenishmentProjection;
require dirname(__DIR__).'/vendor/autoload.php';
require_once dirname(__DIR__).'/packages/module-updates/Release.php';
$stage = null;
$exitCode = 0;
try {
if (count($argv) !== 3) {
throw new RuntimeException('Indique pacote e SHA-256 do manifesto.');
}
$checked = Release::inspect($argv[1], $argv[2]);
$stage = sys_get_temp_dir().'/sgc-purchasing-probe-'.bin2hex(random_bytes(8));
if (! mkdir($stage, 0700)) {
throw new RuntimeException('Não foi possível preparar a prova isolada.');
}
foreach ($checked['contents'] as $path => $content) {
if (! is_dir(dirname($stage.'/'.$path))) {
mkdir(dirname($stage.'/'.$path), 0700, true);
}
file_put_contents($stage.'/'.$path, $content);
}
require $stage.'/bootstrap.php';
$engine = new ReplenishmentProjection;
$policy = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
$result = $engine->calculate($policy, 120000, '2026-11-01');
$rate = $engine->calculate([...$policy, 'horizon_days' => 3, 'daily_rate' => ['numerator_milli' => 1000, 'denominator_days' => 3]], 10000, '2026-11-01');
$lots = $engine->calculate([...$policy, 'horizon_days' => 2], 10000, '2026-11-01', [], [], 0, [], [['quantity' => 10000, 'last_usable_on' => '2026-11-01']]);
if ($result['risk_date'] !== '2026-11-10' || $result['quantity_milli'] !== 84000 || array_column($rate['series'], 'demand_milli') !== [334, 333, 333]
|| $lots['series'][1]['expired_milli'] !== 0 || $lots['series'][1]['balance_milli'] !== -10000) {
throw new RuntimeException('A versão candidata não preserva os resultados do contrato de integração.');
}
$scenarios = ['manual', 'exact_historical_rate', 'lot_expiry'];
if (in_array('procurement.unit_discounts', $checked['manifest']['module']['capabilities'], true)) {
$pricing = new QuotationPricing;
if ($pricing->unit('19.99', 'percent', '12.50')['net_unit_minor'] !== 1749 || $pricing->unit('0.03', 'percent', '50')['net_unit_minor'] !== 2) {
throw new RuntimeException('Descontos incompatíveis com o contrato.');
}
$scenarios[] = 'unit_discounts';
}
if (in_array('procurement.proposal_scoring', $checked['manifest']['module']['capabilities'], true)) {
$row = ['eligible' => true, 'subtotal_minor' => 10000, 'delivery_days' => 2, 'weights' => ['price' => 50, 'quality' => 50, 'delivery' => 0, 'history' => 0, 'payment' => 0],
'evaluation_count' => 2, 'qualified_evaluations' => array_fill(0, 2, ['quality' => '8', 'history' => '8', 'payment' => '8'])];
$ranking = (new ProposalScoring)->rank([['id' => 1, ...$row], ['id' => 2, ...$row]], 2);
if ($ranking !== ['scores' => [1 => 900, 2 => 900], 'top_ids' => [1, 2]]) {
throw new RuntimeException('Pontuação ou empates incompatíveis com o contrato.');
}
$scenarios[] = 'proposal_scores_and_ties';
}
echo json_encode(['version' => $checked['manifest']['version'], 'source_sha256' => $checked['manifest']['source_sha256'], 'runtime' => PHP_VERSION, 'scenarios' => $scenarios, 'passed' => true, 'database_connected' => false], JSON_THROW_ON_ERROR)."\n";
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage()."\n");
$exitCode = 1;
} finally {
if ($stage !== null && is_dir($stage)) {
$real = realpath($stage);
$temporary = str_replace('\\', '/', realpath(sys_get_temp_dir()));
if ($real !== false && str_starts_with(str_replace('\\', '/', $real), $temporary.'/sgc-purchasing-probe-')) {
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) {
if ($file->isDir() && ! $file->isLink()) {
rmdir($file->getPathname());
} else {
unlink($file->getPathname());
}
}
rmdir($real);
}
}
}
exit($exitCode);
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Symfony\Component\Process\Process;
use Tempo\Distribution\Release;
// Identical tooling in both compositions; distribution artifacts are pinned copies.
require_once dirname(__DIR__).'/packages/module-updates/Release.php';
try {
$action = $argv[1] ?? '';
if ($action === 'publish' && count($argv) === 4) {
$result = Release::publish($argv[2], $argv[3]);
} elseif ($action === 'inspect' && count($argv) === 4) {
$result = Release::inspect($argv[2], $argv[3])['manifest'];
} elseif ($action === 'install' && count($argv) === 5) {
require_once dirname(__DIR__).'/vendor/autoload.php';
$host = realpath($argv[2]);
$archive = realpath($argv[3]);
if ($host === false || $archive === false) {
throw new RuntimeException('Instalação ou pacote inexistente.');
}
// Execute only the approved candidate, in an isolated PHP process, before switching.
$probe = new Process([PHP_BINARY, dirname(__DIR__).'/scripts/purchasing-probe.php', $archive, $argv[4]], dirname(__DIR__), timeout: 30);
$probe->mustRun();
$result = Release::install($argv[2], $argv[3], $argv[4]);
$result['runtime_probe'] = json_decode($probe->getOutput(), true, 32, JSON_THROW_ON_ERROR);
} elseif ($action === 'status' && count($argv) === 3) {
$result = Release::current($argv[2]) ?? ['installed' => false];
} elseif ($action === 'rollback' && count($argv) === 3) {
$result = Release::rollback($argv[2]);
} elseif ($action === 'check' && count($argv) === 5) {
$candidate = Release::inspect($argv[3], $argv[4])['manifest'];
$current = Release::current($argv[2]);
if ($current !== null && $current['version'] === $candidate['version'] && $current['manifest_sha256'] !== $argv[4]) {
throw new RuntimeException('A mesma versão tem conteúdo diferente; reconcilie o canal antes de actualizar.');
}
$result = ['installed_version' => $current['version'] ?? null, 'candidate_version' => $candidate['version'],
'available' => $current === null || version_compare($candidate['version'], $current['version'], '>'),
'same_source' => ($current['source_sha256'] ?? null) === $candidate['source_sha256'],
'database_changes' => false, 'permissions_changed' => false];
} else {
throw new RuntimeException('Uso: publish <pacote-origem> <canal>; inspect <zip> <sha-manifesto>; check <host> <zip> <sha-manifesto>; install <host> <zip> <sha-manifesto>; status <host>.');
}
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage()."\n");
exit(1);
}
+91
View File
@@ -0,0 +1,91 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
import {spawn} from 'node:child_process';
import vm from 'node:vm';
async function probe(mode,job,input={},legacy=false){
return new Promise((resolve,reject)=>{
const php=mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
const p=spawn(php,[legacy?'scripts/payables-probe.php':'scripts/settlements-probe.php',mode,job],{windowsHide:true});let out='',err='';
p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw Error('Isolated probe failed '+job+': '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input));
});
}
const hosts=[];
for(const mode of ['compras','sgf']){
const base=`http://127.0.0.1:${mode==='compras'?8007:8008}`;
function client(){const cookies=new Map();let csrf='',context='';return {
async request(path,method='GET',body,key=randomUUID(),form=false){
const r=await fetch(base+path,{redirect:'manual',signal:AbortSignal.timeout(20000),method,headers:{Connection:'close',Accept:'application/json','Content-Type':form?'application/x-www-form-urlencoded':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':context,'Idempotency-Key':key},...(body===undefined?{}:{body:form?new URLSearchParams(body):JSON.stringify(body)})});
for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const html=await r.text();let data;try{data=JSON.parse(html);}catch{}
csrf=html.match(/name="_token" value="([^"]+)"/)?.[1]??data?.csrf??csrf;
const embedded=html.match(/const settings=(.*);/);if(embedded){const s=JSON.parse(embedded[1]);csrf=s.csrf;context=s.context??context;new vm.Script(html.match(/<script>([\s\S]*?)<\/script>/)[1]);}
if(data?.context?.token)context=data.context.token;
return {status:r.status,data,html};
},
async login(role){assert.equal((await this.request('/demo?next=payments')).status,200);assert.equal((await this.request('/demo/entrar','POST',{role,next:'payments',_token:csrf},undefined,true)).status,302);if(mode==='sgf')await this.request('/api/session');await this.request('/supplier-payments');},
async list(){const r=await this.request('/api/supplier-payments');assert.equal(r.status,200);return r.data;},
async send(action,body,status=200,key=randomUUID()){const r=await this.request('/api/supplier-payments/'+action,'POST',body,key);assert.equal(r.status,status,mode+' '+action+' '+r.html.slice(0,200));return r.data;},
async logout(){await this.request('/demo');assert.equal((await this.request('/demo/sair','POST',{_token:csrf},undefined,true)).status,302);}
};}
const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta');
let d=await q.list();const allocation=d.allocations[0],o=d.obligations[0],payment=d.payments[0];
assert.equal(o.balance_minor,760000);assert.equal(payment.state,'confirmed');assert.equal(payment.unallocated_minor,0);assert.equal(d.reversals_available,true);
const before=await probe(mode,'inspect');const original=JSON.stringify(allocation);
const body={allocation_id:Number(allocation.id),source_revision:allocation.source_revision,reason:'Reverter integralmente a afectação sintética para corrigir o montante'};
await p.send('reversal-prepare',{...body,reason:''},422);await p.send('reversal-prepare',{...body,amount:'1000.00'},422);
await p.send('reversal-prepare',{...body,source_revision:'outdated'},409);
await q.send('reversal-prepare',body,403);await v.send('reversal-prepare',body,403);
const race=await probe(mode,'race',{role:'preparacao',action:'reversal-prepare',body});assert.equal(race.waits,2);assert.deepEqual(race.results.map(r=>r.status),[200,200]);
const proposal=race.results[0].result;assert.equal(race.results[1].result.proposal_id,proposal.proposal_id);
const key=randomUUID();await p.send('reversal-prepare',body,200,key);assert.equal((await p.send('reversal-prepare',body,200,key)).replayed,true);
await p.send('reversal-prepare',{...body,reason:'Outro motivo incompatível'},409);await p.send('reversal-prepare',{...body,reason:'Outro motivo incompatível'},409,key);
d=await q.list();assert.equal(d.obligations[0].balance_minor,760000);assert.equal(d.payments[0].unallocated_minor,0);assert.equal(d.reversals.length,0);
const preparePage=await p.request('/supplier-payments');assert(preparePage.html.includes('Proposta de reversão #'));
const validatePage=await v.request('/supplier-payments');assert(validatePage.html.includes('data-action="reversal-confirm"'));
const decision={proposal_id:proposal.proposal_id,reason:'Confirmação independente da reversão integral documentada'};
await q.send('reversal-confirm',decision,403);await p.send('reversal-confirm',decision,403);
await probe(mode,'grant',{role:'preparacao',action:'reversal_confirm',enabled:true});await p.send('reversal-confirm',decision,409);await probe(mode,'grant',{role:'preparacao',action:'reversal_confirm',enabled:false});
await probe(mode,'person',{same:true});await v.send('reversal-confirm',decision,409);await probe(mode,'person',{same:false});
await probe(mode,'source',{override:{total_minor:1160001}},true);await v.send('reversal-confirm',decision,409);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source',{},true);
assert.equal((await probe(mode,'foreign',{action:'reversal-confirm',body:decision})).refused,true);
const revoked=await probe(mode,'race',{role:'validacao',action:'reversal-confirm',body:decision,revoke:'reversal_confirm'});assert.equal(revoked.waits,2);assert.deepEqual(revoked.results.map(r=>r.status),[403,403]);await probe(mode,'grant',{role:'validacao',action:'reversal_confirm',enabled:true});
const counts=await probe(mode,'inspect'),failedKey=randomUUID();await probe(mode,'fault',{enabled:true,kind:'supplier_allocation.reversed'});
await v.send('reversal-confirm',decision,500,failedKey);await probe(mode,'fault',{enabled:false});assert.deepEqual((await probe(mode,'inspect')).counts,counts.counts);
assert.equal((await q.list()).obligations[0].balance_minor,760000);
const reversal=await v.send('reversal-confirm',decision,200,failedKey);assert.equal((await v.send('reversal-confirm',decision,200,failedKey)).replayed,true);
const confirmedRace=await probe(mode,'race',{role:'validacao',action:'reversal-confirm',body:decision});assert.equal(confirmedRace.waits,2);assert.deepEqual(confirmedRace.results.map(r=>r.status),[200,200]);assert(confirmedRace.results.every(r=>r.result.reversal_id===reversal.reversal_id));
await v.send('reversal-confirm',{...decision,reason:'Tentativa de segunda reversão incompatível'},409);
assert.equal((await p.send('reversal-prepare',body)).proposal_id,proposal.proposal_id);
d=await q.list();assert.equal(d.reversals.length,1);assert.equal(d.reversals[0].amount_minor,400000);assert.equal(d.obligations[0].balance_minor,1160000);assert.equal(d.payments[0].unallocated_minor,400000);assert.equal(d.payments[0].state,'confirmed');assert.equal(JSON.stringify(d.allocations[0]),original);
const stages=[{stage:'before_reversal',debt_minor:760000,unallocated_minor:0},{stage:'full_reversal',debt_minor:1160000,unallocated_minor:400000}];
const oldProposal=d.allocation_proposals[0];
await v.send('allocation-decide',{proposal_id:Number(oldProposal.id),decision:'confirmed',reason:oldProposal.decision.reason});
assert.equal((await q.list()).obligations[0].balance_minor,1160000,'Repeating a reversed original must not reapply it');
const plan={payment_id:Number(payment.id),reference:'SIM-CORRECT-3000',allocations:[{obligation_id:Number(o.id),source_revision:o.source_revision,amount:'3000.00'}],reason:'Nova afectação corrigida depois de reversão integral'};
await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-EXCESS',allocations:[{...plan.allocations[0],amount:'4000.01'}]},409);
await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-STALE',allocations:[{...plan.allocations[0],source_revision:'outdated'}]},409);
// A new reference can propose the same old plan after compensation; old references still recover the original.
const samePlan=await p.send('allocation-prepare',{...plan,reference:'SIM-CORRECT-SAME-4000',allocations:[{...plan.allocations[0],amount:'4000.00'}]});
assert.notEqual(samePlan.proposal_id,Number(d.allocation_proposals[0].id));
const oldRef=await p.send('allocation-prepare',{...plan,reference:'SIM-INTERFACE-ALLOC-001',allocations:[{...plan.allocations[0],amount:'4000.00'}]});assert.equal(oldRef.proposal_id,Number(d.allocation_proposals[0].id));
const correction=await p.send('allocation-prepare',plan);assert.equal((await q.list()).obligations[0].balance_minor,1160000);
const correctionDecision={proposal_id:correction.proposal_id,decision:'confirmed',reason:'Confirmação independente da nova afectação de 3 000 MZN'};
const correctionRace=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:correctionDecision});assert.equal(correctionRace.waits,2);assert.deepEqual(correctionRace.results.map(r=>r.status),[200,200]);assert.equal(correctionRace.results[0].result.decision_id,correctionRace.results[1].result.decision_id);
await v.send('allocation-decide',{...correctionDecision,proposal_id:samePlan.proposal_id},409);
assert.equal((await p.send('allocation-prepare',plan)).proposal_id,correction.proposal_id);await v.send('allocation-decide',correctionDecision);
d=await q.list();assert.equal(d.obligations[0].balance_minor,860000);assert.equal(d.payments[0].unallocated_minor,100000);assert.equal(d.obligations[0].principal_minor,1160000);assert.equal(d.obligations[0].due_on,null);assert.equal(d.obligations[0].days_overdue,null);assert.equal(d.allocations.length,2);assert.equal(d.reversals.length,1);assert.equal(d.payments.length,1);
assert.equal(JSON.stringify(d.allocations[0]),original);assert.equal((await probe(mode,'inspect')).original_hash,before.original_hash);
const reversedEvents=d.events.filter(e=>e.kind==='supplier_allocation.reversed');assert.equal(reversedEvents.length,1);assert.notEqual(String(reversedEvents[0].actor_id),String(d.reversal_proposals[0].prepared_by));assert.notEqual(reversedEvents[0].person,d.reversal_proposals[0].prepared_person);
const events=d.events.map(e=>e.id);await q.logout();await q.login('consulta');d=await q.list();assert.deepEqual(d.events.map(e=>e.id),events);assert.equal(d.obligations[0].balance_minor,860000);
const page=await q.request('/supplier-payments');assert(page.html.includes('8 600.00 MZN'));assert(page.html.includes('1 000.00 MZN'));assert(page.html.includes('Revertida integralmente'));assert(page.html.includes('Atraso indeterminado'));assert(!page.html.includes('data-action='));
const obligations=await q.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert(obligations.html.includes('8 600.00 MZN'));
const integrity=await probe(mode,'reversal-immutable');assert.equal(integrity.immutable_operations_refused,4);assert.equal(integrity.invalid_compensations_refused,3);
const final=await probe(mode,'inspect');assert.equal(final.counts.ps_events,final.counts.ps_outbox);assert.equal(final.counts.ps_reversals,1);assert.equal(final.counts.ps_reversal_proposals,1);
stages.push({stage:'corrected_allocation',debt_minor:860000,unallocated_minor:100000});
hosts.push({host:mode,passed:true,stages,due_on:null,days_overdue:null,originals_preserved:true,full_reversals:1,payments:1,allocations:2,events:events.length,outbox:final.counts.ps_outbox,integrity,races:[race,revoked,confirmedRace,correctionRace],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});
console.log(mode+': reversal/correction passed: 7600 -> 11600 -> 8600; unallocated 0 -> 4000 -> 1000.');
}
const hash=createHash('sha256').update(await fs.readFile('packages/payables/src/SupplierSettlements.php')).digest('hex');
await fs.writeFile('docs/proofs/supplier-reversals-20261030.json',JSON.stringify({version:'COMMON-ALLOCATION-FULL-REVERSAL-SIM-v1',checked_at:new Date().toISOString(),core_sha256:hash,hosts,public_enabled:false,bank_execution:false,accounting:false,persistent_demo_upgraded:false,visual_review:false},null,2)+'\n');
+33
View File
@@ -0,0 +1,33 @@
<?php
// Opt-in upgrade of disposable proofs only. Does not upgrade persistent team demos.
$mode = $argv[1] ?? '';
if (! in_array($mode, ['compras', 'sgf'], true)) throw new LogicException('Named proof host required.');
$private = 'C:/laragon/data/sgf-tempo/secrets/';
$s = json_decode(file_get_contents($private.'payables-proof-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
$demo = json_decode(file_get_contents($private.'payables-demo-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
if ($s['DB_DATABASE'] === $demo['DB_DATABASE']) throw new LogicException('Persistent demo protected.');
foreach ($s as $k => $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; }
require $s['HOST_ROOT'].'/vendor/autoload.php';
$app = require $s['HOST_ROOT'].'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$before = []; $columns = [];
foreach (\Tempo\Finance\Payables\SettlementSchema::TABLES as $table) {
$columns[$table] = \Illuminate\Support\Facades\DB::connection()->getSchemaBuilder()->getColumnListing($table);
$before[$table] = \Tempo\Finance\Payables\Payables::canonical(\Illuminate\Support\Facades\DB::table($table)->orderBy($table === 'ps_outbox' ? 'event_id' : 'id')->get()->all());
}
\Tempo\Finance\Payables\ReversalSchema::install();
foreach ($columns as $table => $names) {
$after = \Tempo\Finance\Payables\Payables::canonical(\Illuminate\Support\Facades\DB::table($table)->select($names)->orderBy($table === 'ps_outbox' ? 'event_id' : 'id')->get()->all());
if ($before[$table] !== $after) throw new LogicException('Existing rows changed during upgrade.');
}
$f = json_decode(file_get_contents($s['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR);
\Illuminate\Support\Facades\DB::transaction(function () use ($f): void {
foreach (['preparacao' => 'reversal_prepare', 'validacao' => 'reversal_confirm'] as $role => $action) {
$person = \Illuminate\Support\Facades\DB::table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts'][$role]['id'], 'action' => 'view'])->value('person');
if ($person !== 'synthetic-person-'.$role) throw new LogicException('Synthetic identity required.');
\Illuminate\Support\Facades\DB::table('pa_access')->insert($f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'person' => $person, 'action' => $action]);
}
});
echo "Explicit reversal upgrade and two synthetic grants installed on disposable $mode proof only.\n";
echo "All nine existing settlement tables preserved, including original allocations and decisions.\n";
+2 -2
View File
@@ -72,7 +72,7 @@ for(const mode of ['compras','sgf']){
assert.equal(data.obligations[0].due_on,null);assert.equal(data.obligations[0].days_overdue,null); assert.equal(data.obligations[0].due_on,null);assert.equal(data.obligations[0].days_overdue,null);
const afterFirst=await probe(mode,'inspect',{},true);assert.deepEqual(afterFirst.domain_hashes,before.domain_hashes);if(mode==='compras')assert.equal(afterFirst.legacy_balance,760000); const afterFirst=await probe(mode,'inspect',{},true);assert.deepEqual(afterFirst.domain_hashes,before.domain_hashes);if(mode==='compras')assert.equal(afterFirst.legacy_balance,760000);
const html=await q.request('/supplier-payments');assert.equal(html.status,200);assert(html.text.includes('7 600.00 MZN'));assert(!html.text.includes('<form data-action='));assert(html.text.includes('Dias de atraso: sem valor')); const html=await q.request('/supplier-payments');assert.equal(html.status,200);assert(html.text.includes('7 600.00 MZN'));assert(!html.text.includes('<form data-action='));assert(html.text.includes('Dias de atraso: sem valor'));
const prepHtml=await p.request('/supplier-payments');assert(prepHtml.text.includes('data-action="payment-prepare"'));assert(prepHtml.text.includes('data-action="allocation-prepare"')); const prepHtml=await p.request('/supplier-payments');assert(prepHtml.text.includes('data-action="payment-prepare"'));assert(!prepHtml.text.includes('data-action="allocation-prepare"'),'Fully allocated payment must not be offered again');
assert.equal((await probe(mode,'foreign',{body:decision})).refused,true); assert.equal((await probe(mode,'foreign',{body:decision})).refused,true);
const immutable=await probe(mode,'immutable');assert.equal(immutable.refused,3); const immutable=await probe(mode,'immutable');assert.equal(immutable.refused,3);
const more=[]; const more=[];
@@ -105,5 +105,5 @@ for(const mode of ['compras','sgf']){
races:[revoked,paymentRace,planRace,allocationRevoke,allocationRace,overRace],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}); races:[revoked,paymentRace,planRace,allocationRevoke,allocationRace,overRace],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});
} }
assert.equal(hosts[0].core_sha256,hosts[1].core_sha256); assert.equal(hosts[0].core_sha256,hosts[1].core_sha256);
await fs.writeFile('docs/proofs/supplier-settlements.json',JSON.stringify({version:'COMMON-SUPPLIER-SETTLEMENT-SIM-v1',checked_at:new Date().toISOString(),hosts,bank_execution:false,accounting:false,real_migration:false},null,2)+'\n'); await fs.writeFile(process.env.SETTLEMENTS_PROOF_OUTPUT??'docs/proofs/supplier-settlements.json',JSON.stringify({version:'COMMON-SUPPLIER-SETTLEMENT-SIM-v1',checked_at:new Date().toISOString(),hosts,bank_execution:false,accounting:false,real_migration:false},null,2)+'\n');
console.log('Both hosts passed: explicit supplier settlements, canonical balance, duplicate/concurrent requests, failure recovery and preserved source/stock/budget/history.'); console.log('Both hosts passed: explicit supplier settlements, canonical balance, duplicate/concurrent requests, failure recovery and preserved source/stock/budget/history.');
+3 -3
View File
@@ -1,4 +1,4 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto'; import {randomUUID,createHash} from 'node:crypto';
import vm from 'node:vm'; import vm from 'node:vm';
@@ -69,5 +69,5 @@ for(const mode of ['compras','sgf']) {
hosts.push({host:mode,passed:true,kind:'HTTP pages, form availability, submitted commands and JavaScript syntax; not browser execution',stages,principal_minor:1160000,due_on:null,days_overdue:null,payments:1,allocations:1,repeat_recovered:true,payment_confirmation_repeat_recovered:true,event_counts:eventCounts,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}); hosts.push({host:mode,passed:true,kind:'HTTP pages, form availability, submitted commands and JavaScript syntax; not browser execution',stages,principal_minor:1160000,due_on:null,days_overdue:null,payments:1,allocations:1,repeat_recovered:true,payment_confirmation_repeat_recovered:true,event_counts:eventCounts,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});
console.log(mode+': full synthetic sequence passed; single payment/allocation, debt 11600 before allocation and 7600 after.'); console.log(mode+': full synthetic sequence passed; single payment/allocation, debt 11600 before allocation and 7600 after.');
} }
const core=await fs.readFile('packages/payables/src/SupplierSettlements.php');const coreSha=createHash('sha256').update(core).digest('hex');const accepted=JSON.parse(await fs.readFile('docs/proofs/supplier-settlements.json','utf8'));assert(accepted.hosts.every(h=>h.core_sha256===coreSha),'Financial core changed since accepted proof'); const core=await fs.readFile('packages/payables/src/SupplierSettlements.php');const coreSha=createHash('sha256').update(core).digest('hex');const accepted=JSON.parse(await fs.readFile('docs/proofs/supplier-settlements.json','utf8'));const unchanged=accepted.hosts.every(h=>h.core_sha256===coreSha);assert(unchanged||process.env.SETTLEMENTS_REVERSAL_REGRESSION==='1','Financial core changed since accepted proof');
await fs.writeFile('docs/proofs/supplier-settlements-consolidation-20261030.json',JSON.stringify({checked_at:new Date().toISOString(),core_unchanged:true,core_sha256:coreSha,hosts,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},null,2)+'\n'); await fs.writeFile(process.env.SETTLEMENTS_REVERSAL_REGRESSION==='1'?'docs/proofs/supplier-reversals-baseline-20261030.json':'docs/proofs/supplier-settlements-consolidation-20261030.json',JSON.stringify({checked_at:new Date().toISOString(),core_unchanged:unchanged,core_sha256:coreSha,hosts,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},null,2)+'\n');
+13 -2
View File
@@ -1,4 +1,4 @@
import os,socket,subprocess,time,json,pathlib import os,socket,subprocess,time,json,pathlib,sys
root=pathlib.Path('C:/laragon/www/sgf-tempo');os.chdir(root) root=pathlib.Path('C:/laragon/www/sgf-tempo');os.chdir(root)
php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';node='C:/laragon/tools/sgf-tempo/node-v24.22.1-win-arm64/node.exe';processes=[];created=False php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';node='C:/laragon/tools/sgf-tempo/node-v24.22.1-win-arm64/node.exe';processes=[];created=False
for port in [8007,8008]: for port in [8007,8008]:
@@ -16,7 +16,18 @@ try:
time.sleep(.1) time.sleep(.1)
if not ready:raise RuntimeError('Disposable HTTP host failed') if not ready:raise RuntimeError('Disposable HTTP host failed')
subprocess.run([php,'scripts/settlements-install.php',mode,'proof'],check=True) subprocess.run([php,'scripts/settlements-install.php',mode,'proof'],check=True)
subprocess.run([node,'scripts/settlements-interface-check.mjs'],check=True) if '--adversarial' in sys.argv:
if '--upgraded' in sys.argv:
for mode in ['compras','sgf']:subprocess.run([php,'scripts/reversals-install.php',mode],check=True)
subprocess.run([node,'scripts/settlements-http-proof.mjs'],check=True,env={**os.environ,'SETTLEMENTS_PROOF_OUTPUT':'docs/proofs/supplier-reversals-legacy-regression-20261030.json'})
elif '--reversals' in sys.argv:
subprocess.run([node,'scripts/settlements-interface-check.mjs'],check=True,env={**os.environ,'SETTLEMENTS_REVERSAL_REGRESSION':'1'})
for mode in ['compras','sgf']:subprocess.run([php,'scripts/reversals-install.php',mode],check=True)
subprocess.run([node,'scripts/reversals-http-proof.mjs'],check=True)
else:subprocess.run([node,'scripts/settlements-interface-check.mjs'],check=True)
if '--review' in sys.argv:
print('Isolated review: Compras http://127.0.0.1:8007/demo?next=payments ; SGF http://127.0.0.1:8008/demo?next=payments . Ctrl+C stops and cleans these disposable databases.',flush=True)
while True:time.sleep(1)
finally: finally:
for p in processes: for p in processes:
if p.poll() is None:p.terminate();p.wait(timeout=15) if p.poll() is None:p.terminate();p.wait(timeout=15)
+30 -6
View File
@@ -23,9 +23,10 @@ $select = function (string $role) use ($f, $mode) {
return $u; return $u;
}; };
if ($job === 'inspect') { if ($job === 'inspect') {
$counts = []; foreach (SettlementSchema::TABLES as $table) $counts[$table] = $db->table($table)->count(); $counts = []; foreach (array_merge(SettlementSchema::TABLES, \Tempo\Finance\Payables\ReversalSchema::TABLES) as $table) if ($db->getSchemaBuilder()->hasTable($table)) $counts[$table] = $db->table($table)->count();
$out = ['counts' => $counts, 'allocation_events' => $db->table('ps_events')->where('kind', 'supplier_allocation.confirmed')->count(), $out = ['counts' => $counts, 'allocation_events' => $db->table('ps_events')->where('kind', 'supplier_allocation.confirmed')->count(),
'core_sha256' => hash_file('sha256', __DIR__.'/../packages/payables/src/SupplierSettlements.php')]; 'core_sha256' => hash_file('sha256', __DIR__.'/../packages/payables/src/SupplierSettlements.php'),
'original_hash' => hash('sha256', Payables::canonical([$db->table('ps_payments')->orderBy('id')->get()->all(), $db->table('ps_payment_decisions')->orderBy('id')->get()->all(), $db->table('ps_allocations')->orderBy('id')->first(), $db->table('pa_obligations')->orderBy('id')->get()->all()]))];
} elseif ($job === 'worker') { } elseif ($job === 'worker') {
$u = $select($input['role']); $db->select('select set_config(?, ?, false)', ['application_name', $input['label']]); $u = $select($input['role']); $db->select('select set_config(?, ?, false)', ['application_name', $input['label']]);
try { $out = ['status' => 200, 'result' => app($class)->engine($u)->settlements()->command($input['action'], $input['body'], $input['key'])]; } try { $out = ['status' => 200, 'result' => app($class)->engine($u)->settlements()->command($input['action'], $input['body'], $input['key'])]; }
@@ -56,17 +57,40 @@ if ($job === 'inspect') {
$out = ['waits' => $waits, 'results' => $results]; $out = ['waits' => $waits, 'results' => $results];
} finally { if ($db->transactionLevel()) $db->rollBack(); foreach ($processes as $p) if ($p->isRunning()) $p->stop(); } } finally { if ($db->transactionLevel()) $db->rollBack(); foreach ($processes as $p) if ($p->isRunning()) $p->stop(); }
} elseif ($job === 'fault') { } elseif ($job === 'fault') {
if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='supplier_allocation.confirmed' THEN RAISE EXCEPTION 'PS_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER ps_qa_fault BEFORE INSERT ON ps_events FOR EACH ROW EXECUTE FUNCTION ps_qa_fault()"); $kind = $input['kind'] ?? 'supplier_allocation.confirmed';
if (! in_array($kind, ['supplier_allocation.confirmed', 'supplier_allocation.reversed'], true)) throw new LogicException('Invalid proof fault kind.');
if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='{$kind}' THEN RAISE EXCEPTION 'PS_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER ps_qa_fault BEFORE INSERT ON ps_events FOR EACH ROW EXECUTE FUNCTION ps_qa_fault()");
else $db->unprepared('DROP TRIGGER ps_qa_fault ON ps_events; DROP FUNCTION ps_qa_fault()'); else $db->unprepared('DROP TRIGGER ps_qa_fault ON ps_events; DROP FUNCTION ps_qa_fault()');
$out = ['configured' => true]; $out = ['configured' => true];
} elseif ($job === 'grant') { } elseif ($job === 'grant') {
if (! in_array($input['action'], ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'], true)) throw new LogicException('Invalid QA action.'); if (! in_array($input['action'], ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm', 'reversal_prepare', 'reversal_confirm'], true)) throw new LogicException('Invalid QA action.');
$role = $input['role']; $match = $f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'action' => $input['action']]; $role = $input['role']; $match = $f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'action' => $input['action']];
if ($input['enabled']) $db->table('pa_access')->insertOrIgnore($match + ['person' => 'synthetic-person-'.$role]); else $db->table('pa_access')->where($match)->delete(); if ($input['enabled']) $db->table('pa_access')->insertOrIgnore($match + ['person' => 'synthetic-person-'.$role]); else $db->table('pa_access')->where($match)->delete();
$out = ['changed' => true]; $out = ['changed' => true];
} elseif ($job === 'person') { } elseif ($job === 'person') {
$db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => 'view'])->update(['person' => $input['same'] ? 'synthetic-person-preparacao' : 'synthetic-person-validacao']); $db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => 'view'])->update(['person' => $input['same'] ? 'synthetic-person-preparacao' : 'synthetic-person-validacao']);
$out = ['changed' => true]; $out = ['changed' => true];
} elseif ($job === 'reversal-immutable') {
$refused = 0;
foreach (\Tempo\Finance\Payables\ReversalSchema::TABLES as $table) {
foreach (['update', 'delete'] as $operation) {
try { DB::transaction(function () use ($db, $table, $operation): void {
$q = $db->table($table)->where('id', $db->table($table)->min('id'));
if ($operation === 'delete') $q->delete(); else $q->update(['reason' => 'Forbidden alteration']);
}); } catch (\Illuminate\Database\QueryException) { $refused++; }
}
}
$row = (array) $db->table('ps_reversals')->first(); unset($row['id']);
$proposal = $db->table('ps_reversal_proposals')->where('id', $row['proposal_id'])->first();
$invalid = 0;
foreach ([['amount_minor' => (int) $row['amount_minor'] - 1], ['actor_id' => $proposal->prepared_by, 'person' => $proposal->prepared_person], ['unit_id' => 999999]] as $override) {
try { DB::transaction(fn () => $db->table('ps_reversals')->insert(array_replace($row, $override))); }
catch (\Illuminate\Database\QueryException $e) {
if (! str_contains($e->getMessage(), 'Invalid full allocation compensation')) throw $e;
$invalid++;
}
}
$out = ['immutable_operations_refused' => $refused, 'invalid_compensations_refused' => $invalid];
} elseif ($job === 'immutable') { } elseif ($job === 'immutable') {
$refused = 0; $refused = 0;
foreach (['ps_payments', 'ps_payment_decisions', 'ps_allocations'] as $table) { foreach (['ps_payments', 'ps_payment_decisions', 'ps_allocations'] as $table) {
@@ -86,9 +110,9 @@ if ($job === 'inspect') {
$db->table('company_memberships')->insert(['user_id' => $u->id, 'tenant_id' => $tenant, 'company_id' => $company]); $db->table('company_memberships')->insert(['user_id' => $u->id, 'tenant_id' => $tenant, 'company_id' => $company]);
foreach (['company.view' => null, 'unit.view' => $unit, 'supplier_payables.view' => $unit] as $permission => $which) $db->table('permission_grants')->insert(['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $which, 'permission' => $permission, 'user_id' => $u->id, 'created_at' => $now, 'updated_at' => $now]); foreach (['company.view' => null, 'unit.view' => $unit, 'supplier_payables.view' => $unit] as $permission => $which) $db->table('permission_grants')->insert(['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $which, 'permission' => $permission, 'user_id' => $u->id, 'created_at' => $now, 'updated_at' => $now]);
} }
foreach (['view', 'payments_view', 'payment_confirm', 'allocation_confirm'] as $action) $db->table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => $action, 'person' => 'foreign-synthetic-person']); foreach (['view', 'payments_view', 'payment_confirm', 'allocation_confirm', 'reversal_prepare', 'reversal_confirm'] as $action) $db->table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => $action, 'person' => 'foreign-synthetic-person']);
auth()->guard()->setUser($u); if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $tenant, $company, $unit); auth()->guard()->setUser($u); if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $tenant, $company, $unit);
try { app($class)->engine($u)->settlements()->command('payment-decide', $input['body'], \Illuminate\Support\Str::uuid()->toString()); $out = ['refused' => false]; } try { app($class)->engine($u)->settlements()->command($input['action'] ?? 'payment-decide', $input['body'], \Illuminate\Support\Str::uuid()->toString()); $out = ['refused' => false]; }
catch (\DomainException) { $out = ['refused' => true]; } catch (\DomainException) { $out = ['refused' => true]; }
} elseif ($job === 'multi') { } elseif ($job === 'multi') {
// Core contract test with explicit synthetic source adapter, distinct from the real host source proof. // Core contract test with explicit synthetic source adapter, distinct from the real host source proof.
@@ -10,6 +10,8 @@ use Tests\TestCase;
final class CatalogueAccountingTest extends TestCase final class CatalogueAccountingTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'accounting'; }
use RefreshDatabase; use RefreshDatabase;
private array $f; private array $f;
private string $token; private string $token;
+2
View File
@@ -10,6 +10,8 @@ use Tests\Support\DirectCostFixture;
use Tests\Support\IdentityFixture; use Tests\Support\IdentityFixture;
use Tests\TestCase; use Tests\TestCase;
final class DirectCostsTest extends TestCase { final class DirectCostsTest extends TestCase {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'finance'; }
use RefreshDatabase; use RefreshDatabase;
private array $f;private string $token; private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=DirectCostFixture::create();$this->login();} protected function setUp(): void {parent::setUp();$this->f=DirectCostFixture::create();$this->login();}
@@ -12,6 +12,8 @@ use Tests\TestCase;
final class DocumentPreparationTest extends TestCase final class DocumentPreparationTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase; use RefreshDatabase;
private array $fixture; private array $fixture;
private string $token; private string $token;
+2
View File
@@ -13,6 +13,8 @@ use Tests\TestCase;
final class DocumentPreviewTest extends TestCase final class DocumentPreviewTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase; use RefreshDatabase;
private array $fixture; private array $fixture;
private string $token; private string $token;
+2
View File
@@ -8,6 +8,8 @@ use Illuminate\Support\Str;
use Tests\Support\{IndustrialBudgetFixture,IdentityFixture}; use Tests\Support\{IndustrialBudgetFixture,IdentityFixture};
use Tests\TestCase; use Tests\TestCase;
final class IndustrialBudgetsTest extends TestCase { final class IndustrialBudgetsTest extends TestCase {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'finance'; }
use RefreshDatabase; use RefreshDatabase;
private array $f;private string $token; private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=IndustrialBudgetFixture::create();$this->login();} protected function setUp(): void {parent::setUp();$this->f=IndustrialBudgetFixture::create();$this->login();}
+2
View File
@@ -9,6 +9,8 @@ use Tests\Support\IndustrialCostFixture;
use Tests\Support\IdentityFixture; use Tests\Support\IdentityFixture;
use Tests\TestCase; use Tests\TestCase;
final class IndustrialCostsTest extends TestCase { final class IndustrialCostsTest extends TestCase {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'finance'; }
use RefreshDatabase; use RefreshDatabase;
private array $f;private string $token; private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=IndustrialCostFixture::create();$this->login();} protected function setUp(): void {parent::setUp();$this->f=IndustrialCostFixture::create();$this->login();}
@@ -12,6 +12,8 @@ use Tests\TestCase;
final class InternalConfirmationTest extends TestCase final class InternalConfirmationTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase; use RefreshDatabase;
private array $fixture; private array $fixture;
private string $token; private string $token;
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
// Transactions only: never migrate/fresh or reset the persistent demonstration.
#[\PHPUnit\Framework\Attributes\Group('modular-focused')]
final class ModuleNavigationTest extends TestCase
{
use DatabaseTransactions;
private array $f;
protected function setUp(): void
{
parent::setUp();
$this->f = IdentityFixture::create('-module-'.bin2hex(random_bytes(6)));
foreach (['clients.view','services.view'] as $p) IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$p);
IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);
$this->actingAs($this->f['reader'])->withSession(['auth_version'=>$this->f['reader']->auth_version]);
}
private function select(?string $module, ?int $company=null)
{
return $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$company??$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>$module]);
}
public function test_explicit_selection_direct_access_and_old_generation(): void
{
$this->select(null)->assertOk();
$this->getJson('/api/a/clients')->assertStatus(409);
$token=$this->select('commercial')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk();
$this->getJson('/api/workshop')->assertStatus(409);
$next=$this->select('workshop')->assertOk()->json('context.token');
$this->assertNotSame($token,$next);
$this->withHeader('X-SGF-Context',$token)->getJson('/api/workshop')->assertStatus(409);
$this->withHeader('X-SGF-Context',$next)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
$this->postJson('/api/a/clients',['name'=>'Must not be inserted'])->assertForbidden();
$this->assertFalse(DB::table('a_clients')->where('name','Must not be inserted')->exists());
}
public function test_company_configuration_and_revocation_are_checked_again(): void
{
$token=$this->select('commercial')->assertOk()->json('context.token');
DB::table('permission_grants')->where('user_id',$this->f['reader']->id)->whereIn('permission',['clients.view','services.view'])->delete();
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertForbidden();
$this->getJson('/api/session')->assertOk()->assertJsonPath('context.module_id',null);
$this->select('commercial')->assertForbidden();
$this->select('workshop',$this->f['sibling'])->assertForbidden();
config(['modules.companies.'.$this->f['tenant'].':'.$this->f['company']=>['contracted'=>['workshop'],'enabled'=>[]]]);
$this->select('workshop')->assertForbidden();
}
public function test_development_discovery_never_grants_operations_or_applies_in_production(): void
{
config(['modules.contracted'=>[], 'modules.development_profiles'=>[$this->f['reader']->id.':'.$this->f['tenant'].':'.$this->f['company']]]);
$selected=$this->select('purchasing')->assertOk();
$this->assertCount(6,$selected->json('context.modules'));
$this->withHeader('X-SGF-Context',$selected->json('context.token'))->getJson('/api/supplier-payables')->assertForbidden();
app()->detectEnvironment(fn()=>'production');
$this->getJson('/api/session')->assertOk()->assertJsonPath('context.modules',[])->assertJsonPath('context.module_id',null);
}
public function test_failed_selection_preserves_context_and_audit_contains_module_without_token(): void
{
$selected=$this->select('commercial')->assertOk();
$this->select('invented')->assertForbidden();
$session=$this->getJson('/api/session')->assertOk();
$session->assertJsonPath('context.module_id','commercial')->assertJsonPath('context.token',$selected->json('context.token'));
$audit=DB::table('identity_audit')->where('actor_id',$this->f['reader']->id)->where('action','context.selected')->orderByDesc('id')->first();
$this->assertSame('commercial',json_decode($audit->details,true)['module_id']);
$this->assertStringNotContainsString($selected->json('context.token'),$audit->details);
}
public function test_local_environment_and_copied_profile_cannot_enable_discovery_for_client_database_configuration(): void
{
$this->select(null)->assertOk();
config(['modules.contracted'=>[], 'modules.development_profiles'=>[$this->f['reader']->id.':'.$this->f['tenant'].':'.$this->f['company']]]);
$connection=config('database.default');$original=config("database.connections.$connection.database");
config(["database.connections.$connection.database"=>'sgf_tempo_dev']);
app()->detectEnvironment(fn()=>'local');
try { $this->getJson('/api/session')->assertOk()->assertJsonPath('context.modules',[]); }
finally { config(["database.connections.$connection.database"=>$original]);app()->detectEnvironment(fn()=>'testing'); }
}
public function test_shared_records_require_explicit_association_and_company_scope(): void
{
$actor=$this->f['manager'];
foreach(['clients.view','clients.create','clients.edit'] as $p)IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],$p);
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);
$this->actingAs($actor)->withSession(['auth_version'=>$actor->auth_version]);
$token=$this->select('commercial')->assertOk()->json('context.token');
$this->withHeaders(['X-SGF-Context'=>$token,'Idempotency-Key'=>'module-client-proof-001']);
$id=$this->postJson('/api/a/clients',['name'=>'Module proof client'])->assertCreated()->json('id');
$this->getJson('/api/a/clients')->assertOk()->assertJsonPath('records.0.id',$id);
$token=$this->select('workshop')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id);
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Explicit synthetic association'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertOk()->assertJsonPath('name','Module proof client');
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
$this->assertSame(2,DB::table('identity_audit')->where('action','module.record.associated')->where('company_id',$this->f['company'])->count());
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Correct association without rewriting original','operation'=>'remove'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.modules',['commercial']);
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Re-association explicitly reviewed'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertOk();
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['sibling'],'clients.view');
$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit'],'module_id'=>'commercial'])->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
}
public function test_unassociated_legacy_records_remain_in_explicit_administrative_inventory(): void
{
$id=DB::table('a_clients')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>null,'name'=>'Existing unassociated record','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]);
$token=$this->select('commercial')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[])->assertJsonStructure(['scope_notice']);
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertForbidden();
IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],'access.manage');
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id)->assertJsonPath('records.clients.0.modules',[]);
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Cannot assign without edit permission'])->assertForbidden();
$this->assertSame('Existing unassociated record',DB::table('a_clients')->where('id',$id)->value('name'));
}
public function test_draft_creation_preserves_source_history_and_does_not_share_dependencies_implicitly(): void
{
$actor=$this->f['manager'];
foreach(['clients','services','service_drafts','billing_drafts'] as $area){
foreach(['view','create','edit'] as $action)IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],"$area.$action",str_ends_with($area,'_drafts')?$this->f['unit']:null);
}
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);
$this->actingAs($actor)->withSession(['auth_version'=>$actor->auth_version]);
$token=$this->select('commercial')->assertOk()->json('context.token');
$write=function($area,$body)use($token){return $this->withHeaders(['X-SGF-Context'=>$token,'Idempotency-Key'=>(string)\Illuminate\Support\Str::uuid()])->postJson('/api/a/'.$area,$body)->assertCreated()->json('id');};
$client=$write('clients',['name'=>'Synthetic modular client']);
$service=$write('services',['name'=>'Synthetic modular service']);
$draft=$write('service_drafts',['client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'2','unit_price'=>'75']]]);
$bill=$write('billing_drafts',['source_id'=>$draft]);
$original=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
$this->assertSame($draft,$original['source_id']);
$token=$this->select('workshop')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>99,'module_id'=>'workshop','reason'=>'Reject stale review'])->assertStatus(409);
$this->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>1,'module_id'=>'workshop','reason'=>'Explicitly share this preserved draft'])->assertOk();
$record=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
$this->assertSame($original['data'],$record['data']);
$this->assertSame($original['history'],$record['history']);
$this->assertSame($draft,$record['source_id']);
$this->getJson('/api/a/service_drafts/'.$draft)->assertForbidden();
$this->getJson('/api/a/clients/'.$client)->assertForbidden();
$this->getJson('/api/a/services/'.$service)->assertForbidden();
$this->assertSame(1,DB::table('a_billing_drafts')->where('id',$bill)->value('version'));
}
}
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceCalculationTest extends TestCase final class ServiceCalculationTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase; use RefreshDatabase;
private array $f; private array $f;
private int $draft; private int $draft;
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceFiscalTest extends TestCase final class ServiceFiscalTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'accounting'; }
use RefreshDatabase; use RefreshDatabase;
private array $f; private string $token; private int $draft; private int $service; private array $f; private string $token; private int $draft; private int $service;
protected function setUp(): void protected function setUp(): void
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceInvoiceTest extends TestCase final class ServiceInvoiceTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase; use RefreshDatabase;
private array $f; private string $token; private string $key; private array $f; private string $token; private string $key;
protected function setUp(): void protected function setUp(): void
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceReceivablesTest extends TestCase final class ServiceReceivablesTest extends TestCase
{ {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'finance'; }
use RefreshDatabase; use RefreshDatabase;
private array $f; private string $token; private int $ob; private array $f; private string $token; private int $ob;
protected function setUp(): void { parent::setUp(); $this->f=ReceivablesFixture::create(); $this->login(); } protected function setUp(): void { parent::setUp(); $this->f=ReceivablesFixture::create(); $this->login(); }

Some files were not shown because too many files have changed in this diff Show More