diff --git a/app/Console/Commands/ReplenishmentTick.php b/app/Console/Commands/ReplenishmentTick.php
new file mode 100644
index 0000000..c8ddcc5
--- /dev/null
+++ b/app/Console/Commands/ReplenishmentTick.php
@@ -0,0 +1,33 @@
+ready()) {
+ $this->warn('Reposição durável não instalada nesta composição.');
+
+ return self::FAILURE;
+ }
+ $limit = $this->option('limit') ?? config('replenishment.batch_size');
+ if (! filter_var($limit, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 500]])) {
+ $this->error('Limite entre 1 e 500.');
+
+ return self::INVALID;
+ }
+ $queue->discover();
+ $result = $queue->drain((int) $limit);
+ $this->info('Reposição: '.$result['processed'].' trabalhos conferidos; '.$result['failed'].' a recuperar.');
+
+ return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
+ }
+}
diff --git a/app/Http/Controllers/AutomaticPurchaseController.php b/app/Http/Controllers/AutomaticPurchaseController.php
index 4c0eb20..6641df6 100644
--- a/app/Http/Controllers/AutomaticPurchaseController.php
+++ b/app/Http/Controllers/AutomaticPurchaseController.php
@@ -3,50 +3,82 @@
namespace App\Http\Controllers;
use App\Models\CostCenter;
+use App\Models\Equipment;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\AutomaticPurchases;
use App\Services\OrganizationAccess;
+use App\Services\ReplenishmentQueue;
use App\Services\SupplyPlanning;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
+use Symfony\Component\HttpFoundation\Response;
class AutomaticPurchaseController extends Controller
{
- public function index(Request $request, AutomaticPurchases $service)
+ public function index(Request $request, AutomaticPurchases $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'supply_read', 'supply_prepare', 'supply_review']), 403);
$prepare = app(SupplyPlanning::class)->allows($actor, 'supply_prepare');
$ready = $service->ready();
$planning = app(SupplyPlanning::class);
+ $filters = $request->validate(['need_state' => 'nullable|in:all,pending,created,covered,satisfied', 'rules_page' => 'nullable|integer|min:1|max:1000000', 'needs_page' => 'nullable|integer|min:1|max:1000000', 'history_page' => 'nullable|integer|min:1|max:1000000', 'work_page' => 'nullable|integer|min:1|max:1000000']);
+ $needs = $planning->latest('auto_need');
+ if (($filters['need_state'] ?? 'all') !== 'all') {
+ $needs->where('state', $filters['need_state']);
+ }
Inertia::encryptHistory();
- return Inertia::render('stock/replenishment', [
+ $response = Inertia::render('stock/replenishment', [
'ready' => $ready, 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'),
- 'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->limit(100)->get() : [],
- 'needs' => $ready ? $planning->latest('auto_need')->latest('id')->limit(100)->get() : [],
- 'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->limit(100)->get() : [],
+ 'filters' => ['need_state' => $filters['need_state'] ?? 'all'],
+ 'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString() : null,
+ 'needs' => $ready ? $needs->orderByRaw("CASE WHEN state = 'pending' THEN 0 ELSE 1 END")->oldest('id')->paginate(20, ['*'], 'needs_page')->withQueryString() : null,
+ 'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->paginate(30, ['*'], 'history_page')->withQueryString() : null,
+ ...app(ReplenishmentQueue::class)->status($actor),
+ 'workItems' => app(ReplenishmentQueue::class)->ready() ? DB::table('replenishment_work as w')->join('stock_items as i', 'i.id', '=', 'w.stock_item_id')->join('warehouses as a', 'a.id', '=', 'w.warehouse_id')->where('w.tenant_id', $actor->tenant_id)->where('i.tenant_id', $actor->tenant_id)->where('a.tenant_id', $actor->tenant_id)->where(fn ($q) => $q->whereColumn('w.generation', '>', 'w.processed_generation')->orWhere('w.failures', '>', 0))->orderBy('w.due_at')->orderBy('w.id')->paginate(20, ['w.id', 'i.code', 'i.name', 'a.name as warehouse', 'w.last_error', 'w.due_at', 'w.failures'], 'work_page')->withQueryString() : null,
'policies' => Schema::hasTable('supply_records') && $prepare ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->get() : [],
'requesters' => $prepare ? User::query()->where('tenant_id', $actor->tenant_id)->get(['id', 'name', 'department_id', 'purchasing_role', 'tenant_id'])->filter(fn ($u) => $service->eligibleRequester($u))->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->values() : [],
'centers' => $prepare ? CostCenter::query()->orderBy('code')->get(['id', 'department_id', 'code', 'name']) : [],
- ])->toResponse($request)->withHeaders(['Cache-Control' => 'private, no-store, max-age=0']);
+ 'equipment' => $prepare ? Equipment::query()->orderBy('code')->get(['id', 'code', 'description', 'department_id']) : [],
+ 'catalogueTypes' => $prepare && Schema::hasTable('requisition_catalogue') ? DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->pluck('purchase_type', 'stock_item_id') : [],
+ ])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
}
- public function save(Request $request, AutomaticPurchases $service)
+ public function save(Request $request, AutomaticPurchases $service): RedirectResponse
{
$service->save($request->user('web'), $request->all());
return redirect('/stock/reposicao');
}
- public function evaluate(Request $request, int $id, AutomaticPurchases $service)
+ public function evaluate(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User && ($actor->hasFunctions(['supply_prepare', 'supply_review'])) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$need = SupplyRecord::query()->where('kind', 'auto_need')->findOrFail($id);
- $service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
+ if (app(ReplenishmentQueue::class)->ready()) {
+ $service->signal($actor, $need->stock_item_id, $need->warehouse_id);
+ } else {
+ $service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
+ }
+
+ return redirect('/stock/reposicao');
+ }
+
+ public function recover(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
+ {
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions(['supply_prepare', 'supply_review']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
+ $work = DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->where('id', $id)->firstOrFail();
+ $service->signal($actor, $work->stock_item_id, $work->warehouse_id);
return redirect('/stock/reposicao');
}
diff --git a/app/Http/Controllers/CommonStockReadController.php b/app/Http/Controllers/CommonStockReadController.php
new file mode 100644
index 0000000..c4af11d
--- /dev/null
+++ b/app/Http/Controllers/CommonStockReadController.php
@@ -0,0 +1,49 @@
+findOrFail($request->user()->id);
+ abort_unless($user->tenant_id!==null && $user->hasFunctions([PurchasingRole::Admin,PurchasingRole::Warehouse,PurchasingRole::Procurement,PurchasingRole::Finance])
+ && !app(OrganizationAccess::class)->maintenanceOnly($user),403);
+ $d=$request->validate(['view'=>'sometimes|in:items,warehouses,balances,movements','search'=>'nullable|string|max:100',
+ 'warehouse_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|min:1|max:100','snapshot'=>'sometimes|string|max:8192']);
+ abort_if(array_diff(array_keys($request->query()),array_keys($d))!==[],422);
+ $filters=['view'=>$d['view']??'balances','search'=>$d['search']??'','warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,
+ 'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
+ $binding=config('stock_read.synthetic_sources.'.$user->tenant_id.':'.$user->id);
+ if(!$binding)return response()->json(['status'=>'unavailable','message'=>'Fonte autorizada de stock ainda não ligada a este contexto.'],503)->header('Cache-Control','no-store');
+ abort_unless(app()->environment('testing')&&DB::connection()->getDriverName()==='pgsql',403);
+ $identity=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
+ abort_unless(preg_match('/^stock_read_compras_([a-f0-9]{12})$/D',$identity->name,$m)
+ && $identity->role==='stock_read_qa_'.$m[1] && (int)$identity->port===55439
+ && preg_match('/^[a-f0-9]{12}$/D',(string)config('stock_read.qa_run'))
+ && $identity->cluster==='sgf_modular_regression_'.config('stock_read.qa_run'),403);
+ abort_unless(preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$binding['schema']??'')&&($binding['append_only']??false)===true
+ && ($binding['source_tenant_id']??null)===(int)$user->tenant_id,503,'Fonte de stock incompatível.');
+ if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
+ $root=base_path('packages/inventory-read/releases/0.1.0');
+ abort_unless(is_file($root.'/manifest.json')&&hash_equals(config('stock_read.package_manifest_sha256'),hash_file('sha256',$root.'/manifest.json')),503,'Pacote de consulta incompatível.');
+ $manifest=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
+ foreach(['bootstrap.php','src/InventoryReader.php','src/InventoryReadSession.php'] as $file)
+ abort_unless(is_file($root.'/'.$file)&&isset($manifest['files'][$file])&&hash_equals($manifest['files'][$file],hash_file('sha256',$root.'/'.$file)),503,'Pacote de consulta incompatível.');
+ require_once $root.'/bootstrap.php';
+ $fingerprint=hash('sha256',json_encode(['compras',$user->tenant_id,$user->id,$request->session()->get('work_context'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
+ try { $result=(new \Tempo\Inventory\InventoryReadSession(DB::connection()))->read($binding,$fingerprint,$filters,$d['snapshot']??null); }
+ catch(\Illuminate\Database\QueryException $e){
+ if(!in_array($e->errorInfo[0]??null,['42P01','42703','42883','57014'],true))throw $e;
+ return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
+ }
+ return response()->json($result)->header('Cache-Control','no-store');
+ }
+}
diff --git a/app/Http/Controllers/DocumentaryQualificationController.php b/app/Http/Controllers/DocumentaryQualificationController.php
index 6a85d11..717e7ba 100644
--- a/app/Http/Controllers/DocumentaryQualificationController.php
+++ b/app/Http/Controllers/DocumentaryQualificationController.php
@@ -7,22 +7,30 @@ use App\Models\PurchaseAward;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Requisition;
-use App\Services\ConflictGuard;
+use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
-use App\Services\ProposalComparison;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class DocumentaryQualificationController extends Controller
{
+ private function actor(Request $request): User
+ {
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+
+ return $actor;
+ }
+
private function access(Request $request, int $id): Requisition
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
- return Requisition::query()->visibleTo($request->user())->findOrFail($id);
+ return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
}
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $opening): Response
@@ -32,11 +40,13 @@ class DocumentaryQualificationController extends Controller
$sets = QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->get(['id', 'revision', 'reason', 'created_at']);
$latest = $sets->first() ? QualificationSet::query()->findOrFail($sets->first()->id) : null;
$selected = isset($filters['version']) ? QualificationSet::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filters['version']) : $latest;
+ $focusInput = $request->validate(['quotation_id' => ['nullable', 'integer', 'min:1', Rule::exists('quotations', 'id')->where('tenant_id', $requisition->tenant_id)->where('requisition_id', $requisition->id), Rule::in(array_column($selected->snapshot['proposals'] ?? [], 'id'))]]);
+ $focus = isset($focusInput['quotation_id']) ? (int) $focusInput['quotation_id'] : null;
$proposals = $opening->proposals($requisition);
$basis = $opening->basis($proposals);
- $current = $selected && $selected->id === $latest?->id && $selected->basis_hash === $basis;
+ $workflow = app(DocumentaryQualificationWorkflow::class);
+ $current = $selected !== null && $workflow->currentSet($requisition, $selected);
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
- $open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
$query = QualificationAssessment::query()->where('qualification_set_id', $selected->id ?? 0);
$recordIds = (clone $query)->selectRaw('MAX(id)')->groupBy('quotation_id', 'analyst_id');
$records = QualificationAssessment::query()->whereIn('id', $recordIds)->orderBy('quotation_id')->orderBy('analyst_id')->get();
@@ -46,9 +56,10 @@ class DocumentaryQualificationController extends Controller
'latestId' => $latest->id ?? 0, 'latestCriteria' => $latest->criteria ?? [], 'basisHash' => $basis, 'isCurrent' => (bool) $current,
'proposals' => $proposals,
'closedForChanges' => $closed,
- 'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
- 'canDefine' => $open && $proposals !== [] && $request->user()->hasFunctions([PurchasingRole::Procurement]),
- 'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
+ 'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $this->actor($request)->id,
+ 'focusQuotationId' => $focus,
+ 'canDefine' => $workflow->canDefine($this->actor($request), $requisition),
+ 'canAssess' => $selected !== null && $workflow->canAssess($this->actor($request), $requisition, $selected),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -64,7 +75,7 @@ class DocumentaryQualificationController extends Controller
'criteria.*.label' => 'required|string|min:3|max:500', 'criteria.*.category' => 'required|in:legal,financial,fiscal,technical,administrative', 'criteria.*.required' => 'required|boolean',
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
]);
- $workflow->define($request->user(), $requisition, $data);
+ $workflow->define($this->actor($request), $requisition, $data);
return to_route('qualifications.show', $requisition);
}
@@ -80,7 +91,7 @@ class DocumentaryQualificationController extends Controller
'snapshot' => 'prohibited', 'analyst_id' => 'prohibited', 'tenant_id' => 'prohibited', 'revision' => 'prohibited',
]);
$data['answers'] = array_map(fn ($answer) => [...$answer, 'criterion_id' => (int) $answer['criterion_id']], $data['answers']);
- $workflow->assess($request->user(), $set, $data);
+ $workflow->assess($this->actor($request), $set, $data);
return to_route('qualifications.show', $set->requisition_id);
}
diff --git a/app/Http/Controllers/ExternalPaymentController.php b/app/Http/Controllers/ExternalPaymentController.php
index fa0a9c4..437d816 100644
--- a/app/Http/Controllers/ExternalPaymentController.php
+++ b/app/Http/Controllers/ExternalPaymentController.php
@@ -5,13 +5,17 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ExternalPayment;
use App\Models\Invoice;
+use App\Models\PurchaseOrder;
use App\Models\Supplier;
use App\Models\SupplierBankDetail;
+use App\Models\User;
use App\Services\ExternalPaymentWorkflow;
+use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierBankWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@@ -19,20 +23,27 @@ class ExternalPaymentController extends Controller
{
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
- $canManage = $request->user()->hasFunctions([PurchasingRole::Finance]);
- $payments = ExternalPayment::query()->latest('id')->paginate(15)->through(fn ($payment) => [...$payment->toArray(), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
+ abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
+ $filter = $request->validate(['payment_id' => ['nullable', 'integer', 'min:1', Rule::exists('external_payments', 'id')->where('tenant_id', $actor->tenant_id)], 'invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)]]);
+ $focus = isset($filter['payment_id']) ? (int) $filter['payment_id'] : null;
+ $focusInvoice = isset($filter['invoice_id']) ? (int) $filter['invoice_id'] : null;
+ $canManage = $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
+ $visibleInvoices = Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'));
+ $payments = ExternalPayment::query()->whereIn('invoice_id', (clone $visibleInvoices)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusInvoice !== null, fn ($q) => $q->where('invoice_id', $focusInvoice))->latest('id')->paginate(15)->withQueryString()
+ ->through(fn (ExternalPayment $payment) => [...$payment->toArray(), 'canReview' => $workflow->reviewerEligible($actor, $payment), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
if ($canManage && $payments->count() > 0) {
- app(RequisitionWorkflow::class)->audit($request->user(), 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
+ app(RequisitionWorkflow::class)->audit($actor, 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
}
- $invoices = Invoice::query()->where('status', 'validated')->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
+ $invoices = $visibleInvoices->where('status', 'validated')->when($focus !== null, fn ($q) => $q->whereIn('id', $payments->pluck('invoice_id')))->when($focusInvoice !== null, fn ($q) => $q->whereKey($focusInvoice))->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
$supplier = Supplier::query()->findOrFail($invoice->supplier_id);
$bank = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first();
return ['id' => $invoice->id, 'number' => $invoice->number, 'supplier_id' => $supplier->id, 'supplier_name' => $supplier->name, 'total_minor' => $invoice->total_minor, 'outstanding_minor' => $workflow->outstanding($invoice), 'bank_id' => $bank->id ?? 0, 'bank_revision' => $bank->revision ?? 0, 'bank_state' => app(SupplierBankWorkflow::class)->state($supplier)];
});
Inertia::encryptHistory();
- $response = Inertia::render('payments/index', ['invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
+ $response = Inertia::render('payments/index', ['focusPaymentId' => $focus, 'focusInvoiceId' => $focusInvoice, 'invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -40,17 +51,20 @@ class ExternalPaymentController extends Controller
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Finance]), 403);
$data = $request->validate(['supplier_bank_detail_id' => 'required|integer|min:1', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'paid_on' => 'required|date_format:Y-m-d', 'external_reference' => 'required|string|max:150', 'evidence_reference' => 'required|string|max:1000']);
- $workflow->register($request->user(), $invoice, $data);
+ $workflow->register($actor, $invoice, $data);
return to_route('payments.index');
}
public function review(Request $request, ExternalPayment $payment, ExternalPaymentWorkflow $workflow): RedirectResponse
{
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
$data = $request->validate(['decision' => 'required|in:confirmed,rejected', 'reason' => 'required|string|max:2000', 'independently_verified' => 'accepted']);
- $workflow->review($request->user(), $payment, $data['decision'], $data['reason']);
+ $workflow->review($actor, $payment, $data['decision'], $data['reason']);
return back();
}
diff --git a/app/Http/Controllers/FormalAdmissionController.php b/app/Http/Controllers/FormalAdmissionController.php
index fc316c0..0f90e4d 100644
--- a/app/Http/Controllers/FormalAdmissionController.php
+++ b/app/Http/Controllers/FormalAdmissionController.php
@@ -9,26 +9,35 @@ use App\Models\AdmissionPolicy;
use App\Models\AdmissionReview;
use App\Models\PurchaseAward;
use App\Models\Requisition;
-use App\Services\ConflictGuard;
+use App\Models\User;
use App\Services\FormalAdmission;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class FormalAdmissionController extends Controller
{
+ private function actor(Request $request): User
+ {
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+
+ return $actor;
+ }
+
private function access(Request $request, int $id): Requisition
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
- return Requisition::query()->visibleTo($request->user())->findOrFail($id);
+ return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
}
public function policy(Request $request): Response
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
+ abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory();
$response = Inertia::render('purchases/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -40,7 +49,7 @@ class FormalAdmissionController extends Controller
{
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
$data['enabled'] = (bool) $data['enabled'];
- $workflow->policy($request->user(), $data);
+ $workflow->policy($this->actor($request), $data);
return to_route('admissions.policy');
}
@@ -51,27 +60,27 @@ class FormalAdmissionController extends Controller
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
$adoption = $workflow->adoption($requisition);
$selected = isset($filter['version']) ? AdmissionAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
+ $focusInput = $request->validate(['decision_id' => ['nullable', 'integer', 'min:1', Rule::exists('admission_decisions', 'id')->where('tenant_id', $requisition->tenant_id)->where('admission_adoption_id', $selected->id ?? 0)]]);
+ $focus = isset($focusInput['decision_id']) ? (int) $focusInput['decision_id'] : null;
$current = $selected && $selected->id === $adoption?->id;
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
- $open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
+ $open = $workflow->canParticipate($this->actor($request), $requisition);
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->get();
- $latestIds = $decisions->unique('quotation_id')->pluck('id');
$proposals = collect($opening->proposals($requisition))->map(function ($quote) use ($workflow, $requisition, $decisions) {
$basis = $workflow->basis($requisition, $quote['id']);
return [...$quote, 'basisHash' => $basis['hash'] ?? null, 'clear' => $basis['clear'] ?? false, 'previousId' => $decisions->firstWhere('quotation_id', $quote['id'])->id ?? 0];
});
- $history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->paginate(15)->withQueryString();
- $history->through(function ($decision) use ($workflow, $selected, $current, $latestIds, $requisition, $open, $request) {
- $basis = $workflow->basis($requisition, $decision->quotation_id);
- $fresh = $current && $latestIds->contains($decision->id) && $basis && $basis['hash'] === $decision->basis_hash;
+ $history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('id')->paginate(15)->withQueryString();
+ $history->through(function ($decision) use ($workflow, $selected, $current, $requisition, $request) {
+ $fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $decision);
return [...$decision->toArray(), 'status' => $workflow->status($decision, $selected), 'current' => (bool) $fresh,
'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->get(),
- 'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()];
+ 'canReview' => $fresh && $workflow->canReview($this->actor($request), $requisition, $decision)];
});
Inertia::encryptHistory();
- $response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
+ $response = Inertia::render('purchases/admission', ['focusDecisionId' => $focus, 'requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -81,7 +90,7 @@ class FormalAdmissionController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
- $workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
+ $workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
return to_route('admissions.show', $requisition);
}
@@ -90,7 +99,7 @@ class FormalAdmissionController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'outcome' => 'required|in:admit,exclude,clarify', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
- $workflow->propose($request->user(), $requisition, $data);
+ $workflow->propose($this->actor($request), $requisition, $data);
return to_route('admissions.show', $requisition);
}
@@ -100,7 +109,7 @@ class FormalAdmissionController extends Controller
$adoption = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id);
$this->access($request, $adoption->requisition_id);
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
- $workflow->review($request->user(), $decision, $data['verdict'], $data['reason']);
+ $workflow->review($this->actor($request), $decision, $data['verdict'], $data['reason']);
return to_route('admissions.show', $adoption->requisition_id);
}
diff --git a/app/Http/Controllers/FormalRecommendationController.php b/app/Http/Controllers/FormalRecommendationController.php
index bc04d3c..bb7e640 100644
--- a/app/Http/Controllers/FormalRecommendationController.php
+++ b/app/Http/Controllers/FormalRecommendationController.php
@@ -10,27 +10,35 @@ use App\Models\RecommendationPolicy;
use App\Models\RecommendationReview;
use App\Models\Requisition;
use App\Models\User;
-use App\Services\ConflictGuard;
use App\Services\FormalAdmission;
use App\Services\FormalRecommendation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class FormalRecommendationController extends Controller
{
+ private function actor(Request $request): User
+ {
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+
+ return $actor;
+ }
+
private function access(Request $request, int $id): Requisition
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
- return Requisition::query()->visibleTo($request->user())->findOrFail($id);
+ return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
}
public function policy(Request $request): Response
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
+ abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -42,7 +50,7 @@ class FormalRecommendationController extends Controller
{
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
$data['enabled'] = (bool) $data['enabled'];
- $workflow->policy($request->user(), $data);
+ $workflow->policy($this->actor($request), $data);
return to_route('recommendations.policy');
}
@@ -53,22 +61,24 @@ class FormalRecommendationController extends Controller
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
$adoption = $workflow->adoption($requisition);
$selected = isset($filter['version']) ? RecommendationAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
+ $focusInput = $request->validate(['attempt_id' => ['nullable', 'integer', 'min:1', Rule::exists('recommendation_attempts', 'id')->where('tenant_id', $requisition->tenant_id)->where('recommendation_adoption_id', $selected->id ?? 0)]]);
+ $focus = isset($focusInput['attempt_id']) ? (int) $focusInput['attempt_id'] : null;
$current = $selected && $selected->id === $adoption?->id;
$basis = $workflow->basis($requisition);
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->first();
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
- $open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
- $history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->paginate(10)->withQueryString();
- $history->through(function ($attempt) use ($workflow, $selected, $current, $latest, $basis, $requisition, $open, $request) {
- $fresh = $current && $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
+ $open = $workflow->canParticipate($this->actor($request), $requisition);
+ $history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('revision')->paginate(10)->withQueryString();
+ $history->through(function ($attempt) use ($workflow, $selected, $current, $requisition, $request) {
+ $fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $attempt);
return [...$attempt->toArray(), 'status' => $workflow->status($attempt, $selected), 'current' => (bool) $fresh, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(),
- 'canReview' => $open && $fresh && $workflow->status($attempt, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $attempt) && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $request->user()->id)->exists()];
+ 'canReview' => $fresh && $workflow->canReview($this->actor($request), $requisition, $attempt)];
});
Inertia::encryptHistory();
- $response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
+ $response = Inertia::render('purchases/recommendation', ['focusAttemptId' => $focus, 'requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0,
- 'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id])->toResponse($request);
+ 'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -78,7 +88,7 @@ class FormalRecommendationController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
- $workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
+ $workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
return to_route('recommendations.show', $requisition);
}
@@ -87,7 +97,7 @@ class FormalRecommendationController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
- $workflow->propose($request->user(), $requisition, $data);
+ $workflow->propose($this->actor($request), $requisition, $data);
return to_route('recommendations.show', $requisition);
}
@@ -97,7 +107,7 @@ class FormalRecommendationController extends Controller
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
$this->access($request, $adoption->requisition_id);
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
- $workflow->review($request->user(), $attempt, $data['verdict'], $data['reason']);
+ $workflow->review($this->actor($request), $attempt, $data['verdict'], $data['reason']);
return to_route('recommendations.show', $adoption->requisition_id);
}
@@ -108,7 +118,7 @@ class FormalRecommendationController extends Controller
if (DB::connection()->getDriverName() === 'pgsql') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
- $actor = User::query()->where('tenant_id', $request->user()->tenant_id)->findOrFail($request->user()->id);
+ $actor = User::query()->where('tenant_id', $this->actor($request)->tenant_id)->findOrFail($this->actor($request)->id);
$request->setUserResolver(fn () => $actor);
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
$requisition = $this->access($request, $adoption->requisition_id);
@@ -118,7 +128,7 @@ class FormalRecommendationController extends Controller
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->where('id', '<', $attempt->id)->orderBy('revision')->get()->map(fn ($previous) => ['attempt' => $previous, 'state' => $workflow->status($previous, $adoption), 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $previous->id)->orderBy('id')->get()]);
- return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $request->user()->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
+ return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $this->actor($request)->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
});
}
}
diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php
index 29bff35..2b14622 100644
--- a/app/Http/Controllers/InvoiceController.php
+++ b/app/Http/Controllers/InvoiceController.php
@@ -5,37 +5,57 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
+use App\Models\User;
use App\Services\InventoryDispositions;
use App\Services\InventoryReversals;
use App\Services\InvoiceWorkflow;
+use App\Services\OrganizationAccess;
use App\Services\SupplierPayables;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
use Inertia\Inertia;
-use Inertia\Response;
+use Symfony\Component\HttpFoundation\Response;
class InvoiceController extends Controller
{
public function index(Request $request): Response
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
+ abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
+ $filters = $request->validate(['invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)],
+ 'purchase_order_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_orders', 'id')->where('tenant_id', $actor->tenant_id)]]);
+ $focus = isset($filters['invoice_id']) ? (int) $filters['invoice_id'] : null;
+ $focusOrder = isset($filters['purchase_order_id']) ? (int) $filters['purchase_order_id'] : null;
+ Inertia::encryptHistory();
- return Inertia::render('invoices/index', ['stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(), 'invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
+ $response = Inertia::render('invoices/index', ['focusInvoiceId' => $focus, 'focusOrderId' => $focusOrder, 'stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(),
+ 'invoices' => Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusOrder !== null, fn ($q) => $q->where('purchase_order_id', $focusOrder))->with('items')->latest('id')->paginate(15)->withQueryString()
+ ->through(fn (Invoice $invoice) => [...$invoice->toArray(), 'canValidate' => app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)]),
+ 'orders' => PurchaseOrder::query()->visibleTo($actor)->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]),
+ 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($actor, 'view'), 'canManage' => $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor)])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Finance]), 403);
$decimal = ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
- $data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'required|string|max:30', 'items.*.unit_price' => $decimal, 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
- $workflow->register($request->user(), $order, $data);
+ $data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'sometimes|required|string|max:30', 'items.*.unit_price' => ['sometimes', ...$decimal], 'items.*.tax_rate' => ['sometimes', 'required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
+ $workflow->register($actor, $order, $data);
return to_route('invoices.index');
}
public function validateInvoice(Request $request, Invoice $invoice, InvoiceWorkflow $workflow): RedirectResponse
{
- $workflow->validate($request->user(), $invoice);
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
+ $workflow->validate($actor, $invoice);
return back();
}
diff --git a/app/Http/Controllers/OrganizationConfigurationController.php b/app/Http/Controllers/OrganizationConfigurationController.php
index 70d6147..06dad60 100644
--- a/app/Http/Controllers/OrganizationConfigurationController.php
+++ b/app/Http/Controllers/OrganizationConfigurationController.php
@@ -3,10 +3,12 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
+use App\Models\User;
use App\Services\AiPreparationPlan;
use App\Services\ComparisonPolicy;
use App\Services\IntelligencePolicy;
use App\Services\OrganizationAccess;
+use App\Services\OrganizationActivationPreview;
use App\Services\OrganizationConfiguration;
use App\Services\WorkContext;
use App\Services\WorkflowTasks;
@@ -22,35 +24,45 @@ class OrganizationConfigurationController extends Controller
{
public function index(Request $request, OrganizationConfiguration $service): Response
{
- $actor = $service->administrator($request->user());
- $ready = Schema::hasTable('organization_access');
- $state = $ready ? DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->first() : null;
- Inertia::encryptHistory();
- $response = Inertia::render('company/parameters', ['ready' => $ready, 'active' => (bool) ($state->active ?? false), 'version' => $state->version ?? 1,
- 'workflowTasks' => app(WorkflowTasks::class)->catalogue(),
- 'companyRules' => $this->companyRules((int) $actor->tenant_id),
- 'comparisonPolicy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
- 'intelligencePolicy' => app(IntelligencePolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
- 'intelligenceReady' => Schema::hasColumn('tenants', 'intelligence_policy'),
- 'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
- 'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
- 'permissions' => OrganizationAccess::PERMISSIONS,
- 'workContextConfiguration' => Schema::hasColumn('tenants', 'work_context_policy') ? app(WorkContext::class)->shared($request) : null,
- 'enabledWorkContexts' => app(WorkContext::class)->policy($actor)['enabled'] ?? [],
- 'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR), 'work_contexts' => json_decode($p->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR)]) : [],
- 'memberships' => $ready ? DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->get(['user_id', 'profile_id', 'maintenance', 'expires_at', 'reason']) : [],
- 'users' => DB::table('users')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id']),
- 'departments' => DB::table('departments')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
- 'costCenters' => DB::table('cost_centers')->where('tenant_id', $actor->tenant_id)->orderBy('code')->get(['id', 'code', 'name']),
- 'notice' => $request->session()->get('organization_notice')])->toResponse($request);
- $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+ return DB::transaction(function () use ($request, $service) {
+ if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ }
+ $user = $request->user('web');
+ abort_unless($user instanceof User, 403);
+ $actor = $service->administrator($user);
+ $ready = Schema::hasTable('organization_access');
+ $state = $ready ? DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->first() : null;
+ Inertia::encryptHistory();
+ $response = Inertia::render('company/parameters', ['ready' => $ready, 'active' => (bool) ($state->active ?? false), 'version' => $state->version ?? 1,
+ 'workflowTasks' => app(WorkflowTasks::class)->catalogue(),
+ 'activationPreview' => app(OrganizationActivationPreview::class)->build($actor),
+ 'companyRules' => $this->companyRules((int) $actor->tenant_id),
+ 'comparisonPolicy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
+ 'intelligencePolicy' => app(IntelligencePolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
+ 'intelligenceReady' => Schema::hasColumn('tenants', 'intelligence_policy'),
+ 'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
+ 'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
+ 'permissions' => OrganizationAccess::PERMISSIONS,
+ 'workContextConfiguration' => Schema::hasColumn('tenants', 'work_context_policy') ? app(WorkContext::class)->shared($request) : null,
+ 'enabledWorkContexts' => app(WorkContext::class)->policy($actor)['enabled'] ?? [],
+ 'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR), 'work_contexts' => json_decode($p->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR)]) : [],
+ 'memberships' => $ready ? DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->get(['user_id', 'profile_id', 'maintenance', 'expires_at', 'reason']) : [],
+ 'users' => DB::table('users')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id']),
+ 'departments' => DB::table('departments')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
+ 'costCenters' => DB::table('cost_centers')->where('tenant_id', $actor->tenant_id)->orderBy('code')->get(['id', 'code', 'name']),
+ 'notice' => $request->session()->get('organization_notice')])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
- return $response;
+ return $response;
+ });
}
public function command(Request $request, string $command, OrganizationConfiguration $service): Response
{
- $actor = $service->administrator($request->user());
+ $user = $request->user('web');
+ abort_unless($user instanceof User, 403);
+ $actor = $service->administrator($user);
abort_unless(Schema::hasTable('organization_access'), 503, 'A instalação da parametrização ainda não está concluída.');
$rules = ['version' => 'required|integer|min:1', 'tenant_id' => 'prohibited', 'purchasing_role' => 'prohibited'];
if ($command === 'profile') {
diff --git a/app/Http/Controllers/PurchaseController.php b/app/Http/Controllers/PurchaseController.php
index 7ce7fe3..052ec00 100644
--- a/app/Http/Controllers/PurchaseController.php
+++ b/app/Http/Controllers/PurchaseController.php
@@ -9,7 +9,9 @@ use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\RecommendationAttempt;
use App\Models\Requisition;
+use App\Models\User;
use App\Services\FormalRecommendation;
+use App\Services\OrganizationAccess;
use App\Services\ProposalComparison;
use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\View\View;
@@ -22,15 +24,19 @@ use Inertia\Response;
class PurchaseController extends Controller
{
- private function access(Request $request): void
+ private function access(Request $request): User
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
+ abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+
+ return $actor;
}
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
{
- $this->access($request);
- Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
+ $actor = $this->access($request);
+ Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
$recommendation = app(FormalRecommendation::class);
@@ -50,17 +56,17 @@ class PurchaseController extends Controller
return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
- 'canSelect' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
+ 'canSelect' => ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions([PurchasingRole::Procurement]) && $award === null && $actor->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
- 'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
- 'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->hasFunctions([PurchasingRole::Procurement]),
+ 'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($actor, $award, $requisition),
+ 'canIssue' => $award !== null && $workflow->canIssue($actor, $award, $requisition),
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
]);
}
public function select(Request $request, Requisition $requisition, PurchaseWorkflow $workflow): RedirectResponse
{
- $this->access($request);
+ $actor = $this->access($request);
$data = $request->validate([
'quotation_id' => 'required|integer', 'justification' => 'required|string|min:10|max:5000',
'delivery_date' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_location' => 'required|string|min:3|max:500',
@@ -68,46 +74,46 @@ class PurchaseController extends Controller
'approval_snapshot' => 'prohibited', 'decision_matrix' => 'prohibited', 'created_by' => 'prohibited', 'tenant_id' => 'prohibited',
'override' => 'prohibited', 'exception' => 'prohibited',
]);
- $workflow->select($request->user(), $requisition, $data);
+ $workflow->select($actor, $requisition, $data);
return to_route('purchases.award', $requisition);
}
public function decide(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
{
- $this->access($request);
+ $actor = $this->access($request);
$data = $request->validate(['decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => 'nullable|required_if:decision,rejected|string|max:2000']);
- $workflow->decide($request->user(), $award, $data['decision'], $data['comment'] ?? null);
+ $workflow->decide($actor, $award, $data['decision'], $data['comment'] ?? null);
return to_route('purchases.award', $award->requisition_id);
}
public function issue(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
{
- $this->access($request);
+ $actor = $this->access($request);
$request->validate([
'status' => 'prohibited', 'snapshot' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
'issued_by' => 'prohibited', 'tenant_id' => 'prohibited', 'quotation_id' => 'prohibited', 'override' => 'prohibited', 'exception' => 'prohibited',
]);
- $order = $workflow->issue($request->user(), $award);
+ $order = $workflow->issue($actor, $award);
return to_route('orders.show', $order);
}
public function index(Request $request): Response
{
- $this->access($request);
+ $actor = $this->access($request);
return Inertia::render('purchases/index', [
- 'orders' => PurchaseOrder::query()->visibleTo($request->user())->latest('id')->paginate(15),
- 'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
+ 'orders' => PurchaseOrder::query()->visibleTo($actor)->latest('id')->paginate(15),
+ 'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($actor)->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
]);
}
public function show(Request $request, PurchaseOrder $order): Response
{
- $this->access($request);
- PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
+ $actor = $this->access($request);
+ PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
return Inertia::render('purchases/show', [
'order' => $order->load('items'),
@@ -117,8 +123,8 @@ class PurchaseController extends Controller
public function print(Request $request, PurchaseOrder $order): View
{
- $this->access($request);
- PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
+ $actor = $this->access($request);
+ PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
}
diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php
index 1ef4a59..0826866 100644
--- a/app/Http/Controllers/PurchaseProcessController.php
+++ b/app/Http/Controllers/PurchaseProcessController.php
@@ -27,7 +27,8 @@ class PurchaseProcessController extends Controller
{
public function show(Request $request, Requisition $requisition): Response
{
- $actor = $request->user();
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$requisition->load(['requester:id,name', 'department:id,name', 'steps']);
$purchases = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
@@ -90,10 +91,13 @@ class PurchaseProcessController extends Controller
}
$invoices = $finance && $order ? Invoice::query()->where('purchase_order_id', $order->id)->get(['id', 'number', 'status']) : collect();
$payments = $finance ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->get(['id', 'status']) : collect();
- $stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/facturas' : null];
+ $stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance && $order ? '/facturas?purchase_order_id='.$order->id : null];
$stages[] = ['label' => 'Pagamentos externos', 'state' => $finance ? ($payments->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $payments->count().' registos; '.$payments->where('status', 'confirmed')->count().' confirmados. Não comprova liquidação integral e não executa transferências.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/pagamentos' : null];
foreach ($invoices as $invoice) {
- $documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas'];
+ $documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id];
+ }
+ foreach ($payments as $payment) {
+ $documents[] = ['label' => 'Registo externo #'.$payment->id, 'kind' => 'Pagamento externo', 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id];
}
$cancellation = $award ? AwardCancellation::query()->where('purchase_award_id', $award->id)->first(['id', 'status']) : null;
$actors = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', array_column($decisions, 'actor_id'))->pluck('name', 'id');
diff --git a/app/Http/Controllers/QuotationController.php b/app/Http/Controllers/QuotationController.php
index f8bc1c4..0bebbed 100644
--- a/app/Http/Controllers/QuotationController.php
+++ b/app/Http/Controllers/QuotationController.php
@@ -4,13 +4,15 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
-use App\Models\PurchaseAward;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\Supplier;
+use App\Models\User;
use App\Services\ComparisonPolicy;
use App\Services\ConflictGuard;
+use App\Services\CurrentActor;
use App\Services\ProposalComparison;
+use App\Services\QuotationParticipation;
use App\Services\QuotationPricing;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
@@ -18,47 +20,65 @@ use Brick\Math\RoundingMode;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
+use Symfony\Component\HttpFoundation\Response as HttpResponse;
class QuotationController extends Controller
{
- private function access(Request $request): void
+ private function access(Request $request): User
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+
+ return $actor;
}
public function index(Request $request): Response
{
- $this->access($request);
+ $actor = $this->access($request);
- return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($request->user())->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
+ return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($actor)->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
}
- public function show(Request $request, Requisition $requisition): Response
+ public function show(Request $request, Requisition $requisition): HttpResponse
{
- $this->access($request);
- Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
+ $actor = $this->access($request);
+ Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
+ $filters = $request->validate(['quotation_id' => ['nullable', 'integer', Rule::exists('quotations', 'id')->where('tenant_id', $actor->tenant_id)->where('requisition_id', $requisition->id)], 'prepare' => 'nullable|boolean']);
+ $access = app(QuotationParticipation::class);
+ $quotes = Quotation::query()->where('requisition_id', $requisition->id)->with('supplier')->get()->keyBy('id');
$guard = app(ConflictGuard::class);
$result = app(ProposalComparison::class)->inspect($requisition);
- $scores = array_map(fn ($row) => [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $request->user())], $result['rows']);
- $closed = PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
+ $scores = array_map(function ($row) use ($quotes, $actor, $access, $requisition, $guard) {
+ $quote = $quotes->get($row['id']);
- return Inertia::render('quotations/show', [
+ return [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $actor), 'canEvaluate' => $row['eligible'] && $quote !== null && $access->canEvaluate($actor, $requisition, $quote)];
+ }, $result['rows']);
+ Inertia::encryptHistory();
+
+ $response = Inertia::render('quotations/show', [
'requisition' => $requisition->load('items'), 'quotations' => $scores,
'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']),
- 'canCreate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
- 'canEvaluate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]) && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
+ 'canCreate' => $access->canPrepare($actor, $requisition),
+ 'canEvaluate' => $access->canEvaluateProcess($actor, $requisition),
+ 'focusQuotationId' => isset($filters['quotation_id']) ? (int) $filters['quotation_id'] : null,
+ 'prepare' => (bool) ($filters['prepare'] ?? false),
'ready' => $result['ready'],
'topIds' => $result['top_ids'],
'comparisonPolicy' => $result['policy'],
- ]);
+ ])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
}
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement]), 403);
+ $actor = $this->access($request);
+ abort_unless($actor->hasFunctions([PurchasingRole::Procurement]), 403);
$data = $request->validate([
'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100',
'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today',
@@ -68,17 +88,14 @@ class QuotationController extends Controller
'items.*.discount_type' => 'sometimes|required|in:none,percent,amount',
'items.*.discount_value' => 'required_with:items.*.discount_type|numeric|decimal:0,2|min:0|max:1000000000',
]);
- DB::transaction(function () use ($request, $requisition, $data, $workflow) {
+ DB::transaction(function () use ($actor, $requisition, $data, $workflow) {
$guard = app(ConflictGuard::class);
- $guard->lockCompany($request->user());
+ $guard->lockCompany($actor);
+ $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
- abort_unless($requisition->status === 'approved', 403);
- if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
- throw ValidationException::withMessages(['award' => 'As propostas estão fechadas pela adjudicação deste processo.']);
- }
- $guard->assertParticipation($request->user(), $requisition);
+ app(QuotationParticipation::class)->assertPrepare($actor, $requisition);
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
- $guard->assertProcess($requisition, $supplier->id, $request->user());
+ $guard->assertProcess($requisition, $supplier->id, $actor);
if (! $supplier->eligible()) {
throw ValidationException::withMessages(['supplier_id' => 'O fornecedor precisa de estar activo, sem impedimento e com revisão válida.']);
}
@@ -108,20 +125,20 @@ class QuotationController extends Controller
if ($subtotal + $tax > 100000000000000) {
throw ValidationException::withMessages(['items' => 'Valor total acima do limite suportado.']);
}
- $items[] = ['tenant_id' => $request->user()->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
+ $items[] = ['tenant_id' => $actor->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
}
if ($subtotal <= 0) {
throw ValidationException::withMessages(['items' => 'O total arredondado deve ser pelo menos 0,01 MZN.']);
}
$quote = Quotation::query()->create([
- 'tenant_id' => $request->user()->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $request->user()->id,
+ 'tenant_id' => $actor->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $actor->id,
'reference' => $data['reference'], 'delivery_days' => $data['delivery_days'], 'valid_until' => $data['valid_until'], 'payment_terms' => $data['payment_terms'],
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'currency' => 'MZN',
'weights' => app(ComparisonPolicy::class)->forRequest($requisition)['weights'],
'commercial_terms' => array_filter($terms, fn ($term) => $term['discount_type'] !== 'none') === [] ? null : ['version' => 1, 'discount_basis' => 'unit_before_vat', 'rounding' => 'net_unit_half_up_cent', 'lines' => $terms],
]);
$quote->items()->createMany($items);
- $workflow->audit($request->user(), 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
+ $workflow->audit($actor, 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
});
return back();
@@ -129,28 +146,18 @@ class QuotationController extends Controller
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
{
- $this->access($request);
- abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ $actor = $this->access($request);
+ abort_unless($actor->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']);
- DB::transaction(function () use ($request, $quotation, $data, $workflow) {
+ DB::transaction(function () use ($actor, $quotation, $data, $workflow) {
$guard = app(ConflictGuard::class);
- $guard->lockCompany($request->user());
- $requisition = Requisition::query()->visibleTo($request->user())->lockForUpdate()->findOrFail($quotation->requisition_id);
- abort_unless($requisition->status === 'approved' && $requisition->requester_id !== $request->user()->id, 403);
- if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
- throw ValidationException::withMessages(['award' => 'As avaliações estão fechadas pela adjudicação deste processo.']);
- }
- $guard->assertParticipation($request->user(), $requisition);
+ $guard->lockCompany($actor);
+ $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
+ $requisition = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($quotation->requisition_id);
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
- $guard->assertProcess($requisition, $quotation->supplier_id, $request->user());
- if ($quotation->valid_until < today() || ! $quotation->supplier?->eligible()) {
- throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
- }
- if ($quotation->evaluations()->where('evaluator_id', $request->user()->id)->exists()) {
- throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
- }
- $evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $request->user()->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $request->user()->id]);
- $workflow->audit($request->user(), 'quotation.evaluated', $requisition, null, $evaluation->toArray());
+ app(QuotationParticipation::class)->assertEvaluate($actor, $requisition, $quotation);
+ $evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $actor->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $actor->id]);
+ $workflow->audit($actor, 'quotation.evaluated', $requisition, null, $evaluation->toArray());
});
return back();
diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php
index b97669e..10526dc 100644
--- a/app/Http/Controllers/RequisitionController.php
+++ b/app/Http/Controllers/RequisitionController.php
@@ -17,6 +17,7 @@ use App\Services\ApprovalRouting;
use App\Services\ComparisonPolicy;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
+use App\Services\InternalSupplyTrace;
use App\Services\OrganizationAccess;
use App\Services\RequisitionCatalogue;
use App\Services\RequisitionWorkflow;
@@ -31,18 +32,26 @@ use Inertia\Response;
class RequisitionController extends Controller
{
+ private function actor(Request $request): User
+ {
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
+
+ return $actor;
+ }
+
public function index(Request $request): Response
{
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])], 'view' => ['nullable', Rule::in(['all', 'mine', 'approvals', 'decided'])]]);
- $query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
+ $query = Requisition::query()->visibleTo($this->actor($request))->with(['requester:id,name', 'department:id,name', 'steps']);
if (($filters['view'] ?? 'all') === 'mine') {
- $query->where('requester_id', $request->user()->id);
+ $query->where('requester_id', $this->actor($request)->id);
} elseif (($filters['view'] ?? 'all') === 'approvals') {
$eligible = (clone $query)->where('status', 'pending')->get()
- ->filter(fn (Requisition $requisition) => $requisition->canDecide($request->user()))->modelKeys();
+ ->filter(fn (Requisition $requisition) => $requisition->canDecide($this->actor($request)))->modelKeys();
$query->whereIn('id', $eligible);
} elseif (($filters['view'] ?? 'all') === 'decided') {
- $query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $request->user()->id)->whereIn('decision', ['approved', 'rejected']));
+ $query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $this->actor($request)->id)->whereIn('decision', ['approved', 'rejected']));
}
if (! empty($filters['search'])) {
$query->where('number', 'like', '%'.$filters['search'].'%');
@@ -59,12 +68,12 @@ class RequisitionController extends Controller
public function create(Request $request): Response
{
- abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
+ abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
return Inertia::render('requisitions/form', [
- 'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
+ 'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
'requisition' => null,
- 'defaultTaxRate' => (string) (Tenant::query()->findOrFail($request->user()->tenant_id)->default_tax_rate ?? '16.00'),
+ 'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
...$this->equipmentOptions($request),
'catalogue' => $this->catalogueOptions($request),
]);
@@ -72,28 +81,29 @@ class RequisitionController extends Controller
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
{
- $requisition = $workflow->save($request->user(), $request->validated());
+ $requisition = $workflow->save($this->actor($request), $request->validated());
return to_route('requisitions.show', $requisition);
}
public function show(Request $request, Requisition $requisition): Response
{
- Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
+ Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id);
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
return Inertia::render('requisitions/show', [
'requisition' => $requisition,
- 'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $request->user()),
- 'canRefreshRouting' => $requisition->status === 'pending' && $request->user()->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($request->user()) && Schema::hasTable('workflow_notices'),
+ 'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $this->actor($request)),
+ 'canRefreshRouting' => $requisition->status === 'pending' && $this->actor($request)->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($this->actor($request)) && Schema::hasTable('workflow_notices'),
'replenishmentSource' => app(SupplySuggestions::class)->origin($requisition),
+ 'automaticSource' => app(InternalSupplyTrace::class)->purchaseOrigin($this->actor($request), $requisition),
'comparisonPolicy' => app(ComparisonPolicy::class)->forRequest($requisition),
- 'canEdit' => app(OrganizationAccess::class)->canRequest($request->user()) && $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
- 'canDecide' => $requisition->canDecide($request->user()),
- 'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
- 'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
+ 'canEdit' => app(OrganizationAccess::class)->canRequest($this->actor($request)) && $requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft',
+ 'canDecide' => $requisition->canDecide($this->actor($request)),
+ 'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($this->actor($request), $requisition),
+ 'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at', 'after'])->map(fn ($a) => ['id' => $a->id, 'action' => $a->action, 'user_id' => $a->user_id, 'created_at' => $a->created_at, 'execution' => $a->after['execution']['mode'] ?? null]),
'technical' => [
- 'canReview' => app(EquipmentWorkflow::class)->canReview($request->user(), $requisition),
+ 'canReview' => app(EquipmentWorkflow::class)->canReview($this->actor($request), $requisition),
'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition),
'reviews' => TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(),
],
@@ -102,13 +112,13 @@ class RequisitionController extends Controller
public function edit(Request $request, Requisition $requisition): Response
{
- abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
- abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
+ abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
+ abort_unless($requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft', 403);
return Inertia::render('requisitions/form', [
'requisition' => $requisition->load('items'),
- 'defaultTaxRate' => (string) (Tenant::query()->findOrFail($request->user()->tenant_id)->default_tax_rate ?? '16.00'),
- 'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
+ 'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
+ 'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
...$this->equipmentOptions($request),
'catalogue' => $this->catalogueOptions($request),
]);
@@ -117,7 +127,7 @@ class RequisitionController extends Controller
/** @return array */
private function equipmentOptions(Request $request): array
{
- $equipment = Equipment::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(['id', 'code', 'description']);
+ $equipment = Equipment::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(['id', 'code', 'description']);
$parts = SparePart::query()->whereIn('id', EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->select('spare_part_id'))->get(['id', 'stock_item_id']);
return ['equipment' => $equipment, 'spareParts' => $parts, 'stockItems' => StockItem::query()->whereIn('id', $parts->pluck('stock_item_id'))->get(['id', 'code', 'name', 'unit'])];
@@ -128,20 +138,20 @@ class RequisitionController extends Controller
{
$search = $request->validate(['catalogo' => 'nullable|string|max:100'])['catalogo'] ?? '';
- return app(RequisitionCatalogue::class)->options($request->user(), $search);
+ return app(RequisitionCatalogue::class)->options($this->actor($request), $search);
}
public function technical(Request $request, Requisition $requisition): RedirectResponse
{
$data = $request->validate(['basis_hash' => 'required|string|size:64', 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'reason' => 'required|string|max:4000']);
- app(EquipmentWorkflow::class)->review($request->user(), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
+ app(EquipmentWorkflow::class)->review($this->actor($request), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
return back();
}
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
- $workflow->save($request->user(), $request->validated(), $requisition);
+ $workflow->save($this->actor($request), $request->validated(), $requisition);
return to_route('requisitions.show', $requisition);
}
@@ -149,7 +159,7 @@ class RequisitionController extends Controller
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$command = $request->validate(['operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1']);
- $workflow->submit($request->user(), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
+ $workflow->submit($this->actor($request), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
return back();
}
@@ -161,7 +171,7 @@ class RequisitionController extends Controller
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
'operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1',
]);
- $workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
+ $workflow->decide($this->actor($request), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
return back();
}
@@ -174,20 +184,20 @@ class RequisitionController extends Controller
if ($contexts->enforced($actor)) {
return Inertia::render('work-context', ['context' => $contexts->shared($request)]);
}
- $visible = Requisition::query()->visibleTo($request->user());
+ $visible = Requisition::query()->visibleTo($this->actor($request));
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
return Inertia::render('dashboard', [
- 'company' => $request->user()->tenant_id ? Tenant::query()->find($request->user()->tenant_id) : null,
+ 'company' => $this->actor($request)->tenant_id ? Tenant::query()->find($this->actor($request)->tenant_id) : null,
'stats' => [
'total' => (clone $visible)->count(),
'pending' => $pending->count(),
'approved' => (clone $visible)->where('status', 'approved')->count(),
'approvedMinor' => (clone $visible)->where('status', 'approved')->sum('total_minor'),
- 'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->count(),
+ 'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->count(),
],
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
- 'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->take(5)->values(),
+ 'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->take(5)->values(),
]);
}
}
diff --git a/app/Http/Controllers/StockOperationController.php b/app/Http/Controllers/StockOperationController.php
index 1b94038..7afcbf0 100644
--- a/app/Http/Controllers/StockOperationController.php
+++ b/app/Http/Controllers/StockOperationController.php
@@ -49,6 +49,7 @@ class StockOperationController extends Controller
}
return Inertia::render('stock/operations', [
+ 'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && ! app(\App\Services\OrganizationAccess::class)->maintenanceOnly($actor),
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->map(fn ($r) => $r->data['reference']),
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'users' => User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
diff --git a/app/Http/Controllers/WarehouseExchangeController.php b/app/Http/Controllers/WarehouseExchangeController.php
new file mode 100644
index 0000000..82bcd75
--- /dev/null
+++ b/app/Http/Controllers/WarehouseExchangeController.php
@@ -0,0 +1,21 @@
+user('web');
+ abort_unless($actor instanceof User, 403);
+ $input = $request->validate(['cursor' => 'nullable|string|max:4096', 'limit' => 'sometimes|integer|min:1|max:100']);
+
+ return response()->json($exchange->page($actor, $input['cursor'] ?? null, (int) ($input['limit'] ?? 50)))
+ ->header('Cache-Control', 'private, no-store, max-age=0');
+ }
+}
diff --git a/app/Http/Controllers/WarehousePilotController.php b/app/Http/Controllers/WarehousePilotController.php
new file mode 100644
index 0000000..26ef226
--- /dev/null
+++ b/app/Http/Controllers/WarehousePilotController.php
@@ -0,0 +1,60 @@
+user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
+ $report = DB::transaction(function () use ($actor, $service) {
+ if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ }
+
+ return $service->inspect($actor);
+ });
+ $rows = [['Conferido em', 'Âmbito', 'Conferência', 'Resultado', 'Observação', 'Onde configurar']];
+ foreach ($report['checks'] as $check) {
+ $rows[] = [$report['checked_at'], 'SGC autónomo · diagnóstico de configuração', $check['label'], $check['ok'] ? 'Conferido' : 'A preparar', $check['detail'], $check['path'] ?? ''];
+ }
+
+ return response()->streamDownload(static function () use ($rows) {
+ $stream = fopen('php://output', 'wb');
+ if ($stream === false) {
+ throw new \RuntimeException('Não foi possível preparar a exportação.');
+ }
+ fwrite($stream, "\xEF\xBB\xBF");
+ foreach ($rows as $row) {
+ $safe = array_map(static fn (string $value) => preg_match('/^[\s\x{FEFF}]*[=+@-]/u', $value) ? "'".$value : $value, $row);
+ fputcsv($stream, $safe, ';', '"', '');
+ }
+ fclose($stream);
+ }, 'preparacao-piloto-sgc-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
+ }
+
+ public function index(Request $request, WarehousePilotReadiness $service): Response
+ {
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
+
+ return DB::transaction(function () use ($actor, $request, $service) {
+ if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ }
+ Inertia::encryptHistory();
+ $response = Inertia::render('stock/pilot', ['report' => $service->inspect($actor), 'syntheticEvaluation' => app()->environment(['testing', 'payable-testing'])])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ });
+ }
+}
diff --git a/app/Http/Controllers/WarehouseRequestController.php b/app/Http/Controllers/WarehouseRequestController.php
index d8af021..1d6e8b6 100644
--- a/app/Http/Controllers/WarehouseRequestController.php
+++ b/app/Http/Controllers/WarehouseRequestController.php
@@ -8,41 +8,66 @@ use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\WarehouseRequest;
+use App\Services\InternalSupplyTrace;
use App\Services\OrganizationAccess;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
+use App\Services\WarehouseWorkdesk;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
+use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WarehouseRequestController extends Controller
{
- public function index(Request $request): Response
+ public function index(Request $request, WarehouseWorkdesk $desk): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
- $prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
- $ready = Schema::hasTable('warehouse_requests');
- $query = WarehouseRequest::query();
- if (! $actor->hasFunctions(['admin', 'warehouse_request_read']) && ! ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))) {
- $query->where(function ($q) use ($actor) {
- $q->where('created_by', $actor->id);
- if ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null) {
- $q->orWhere('snapshot->requester->department_id', $actor->department_id);
- }
- });
- }
- Inertia::encryptHistory();
- $response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'),
- 'records' => $ready ? $query->latest('id')->paginate(15, ['id', 'status', 'snapshot', 'created_at'])->withQueryString()->through(fn ($r) => ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => app(WarehouseFulfilment::class)->progress($r)]) : null,
- 'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
- 'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
- 'costCenters' => $prepare ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
- 'equipment' => $prepare ? Equipment::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'description']) : []])->toResponse($request);
- $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
- return $response;
+ return DB::transaction(function () use ($request, $actor, $desk) {
+ if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ }
+ $filters = $request->validate(['view' => 'nullable|in:all,review,reserve,delivery,receipt', 'state' => 'nullable|in:all,draft,submitted,authorized,partial,delivered,closed,rejected,cancelled', 'search' => 'nullable|string|max:100', 'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], 'page' => 'nullable|integer|min:1|max:1000000']);
+ $prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
+ $ready = Schema::hasTable('warehouse_requests');
+ $base = $desk->visible($actor);
+ if (! empty($filters['warehouse_id'])) {
+ $base->where('warehouse_id', $filters['warehouse_id']);
+ }
+ if (! empty($filters['search'])) {
+ $search = trim($filters['search']);
+ if (preg_match('/^(?:RA-)?([1-9][0-9]{0,9})$/iD', $search, $match)) {
+ $base->whereKey($match[1]);
+ } else {
+ $base->where(fn ($q) => $q->whereLike('snapshot->requester->name', '%'.$search.'%')->orWhereLike('snapshot->destination', '%'.$search.'%'));
+ }
+ }
+ $views = $ready ? $desk->views(clone $base, $actor) : [];
+ $query = $desk->forView(clone $base, $actor, $filters['view'] ?? 'all');
+ $states = $ready ? DB::query()->fromSub((clone $query)->selectRaw($desk->stateExpression().' as projected_state')->toBase(), 'request_states')->selectRaw('projected_state, COUNT(*) as total')->groupBy('projected_state')->pluck('total', 'projected_state') : [];
+ if (($filters['state'] ?? 'all') !== 'all') {
+ $query->whereRaw($desk->stateExpression().' = ?', [$filters['state']]);
+ }
+ Inertia::encryptHistory();
+ $response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'),
+ 'filters' => ['view' => $filters['view'] ?? 'all', 'state' => $filters['state'] ?? 'all', 'search' => $filters['search'] ?? '', 'warehouse_id' => $filters['warehouse_id'] ?? null], 'views' => $views, 'states' => $states, 'checkedAt' => now()->toIso8601String(),
+ 'records' => $ready ? $query->oldest('id')->paginate(15, ['id', 'tenant_id', 'warehouse_id', 'created_by', 'status', 'snapshot', 'created_at'])->withQueryString()->through(function ($r) use ($desk, $actor) {
+ $p = app(WarehouseFulfilment::class)->progress($r);
+
+ return ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => $p, 'health' => $desk->health($r, $p), 'actions' => $desk->actions($actor, $r, $p)];
+ }) : null,
+ 'warehouses' => Warehouse::query()->when(! $prepare, fn ($q) => $q->whereIn('id', $ready ? $desk->visible($actor)->select('warehouse_id') : []))->orderBy('code')->get(['id', 'code', 'name']),
+ 'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
+ 'costCenters' => $prepare ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
+ 'equipment' => $prepare ? Equipment::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'description']) : []])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ });
}
public function store(Request $request, WarehouseRequests $service): Response
@@ -58,22 +83,24 @@ class WarehouseRequestController extends Controller
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
- $r = WarehouseRequest::query()->findOrFail($id);
- $owner = $r->created_by === $actor->id && $actor->hasFunctions(['warehouse_request_prepare']);
- $review = $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $r->snapshot['requester']['department_id'];
- $deliver = $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']);
- abort_unless($owner || $review || $deliver || $actor->hasFunctions(['admin', 'warehouse_request_read']), 403);
- $p = $service->progress($r);
- $maintenance = app(OrganizationAccess::class)->maintenanceOnly($actor);
- Inertia::encryptHistory();
- $response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'),
- 'canSubmit' => ! $maintenance && $owner && $p['state'] === 'draft', 'canReview' => ! $maintenance && $review && ! $owner && $r->created_by !== $actor->id && $p['state'] === 'submitted',
- 'canDeliver' => ! $maintenance && $deliver && $r->created_by !== $actor->id && $p['reviewer_id'] !== $actor->id && in_array($p['state'], ['authorized', 'partial'], true),
- 'canManageReservation' => ! $maintenance && $review && ! $owner && in_array($p['state'], ['authorized', 'partial'], true),
- 'canReceive' => ! $maintenance && $owner])->toResponse($request);
- $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
- return $response;
+ return DB::transaction(function () use ($request, $id, $service, $actor) {
+ if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ }
+ $r = WarehouseRequest::query()->findOrFail($id);
+ $desk = app(WarehouseWorkdesk::class);
+ abort_unless($desk->canView($actor, $r), 403);
+ $p = $service->progress($r);
+ Inertia::encryptHistory();
+ $response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'),
+ 'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && !app(OrganizationAccess::class)->maintenanceOnly($actor),
+ 'health' => $desk->health($r, $p), 'people' => $desk->people($r, $p),
+ 'supplyTrace' => app(InternalSupplyTrace::class)->forRequest($actor, $r, $p), ...$desk->actions($actor, $r, $p)])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ });
}
public function command(Request $request, int $id, WarehouseFulfilment $service): Response
diff --git a/app/Http/Controllers/WarehouseReturnController.php b/app/Http/Controllers/WarehouseReturnController.php
new file mode 100644
index 0000000..94263e0
--- /dev/null
+++ b/app/Http/Controllers/WarehouseReturnController.php
@@ -0,0 +1,94 @@
+user('web');
+ abort_unless($actor instanceof User, 403);
+
+ return $actor;
+ }
+
+ public function page(Request $r, WarehouseReturns $flow): Response
+ {
+ $flow->authorize($this->actor($r), 'read');
+ $d = $r->validate(['request_id' => 'sometimes|integer|min:1']);
+ $actor = $this->actor($r);
+ $source = isset($d['request_id']) ? $flow->availableDeliveries($actor, (int) $d['request_id'], 1) : null;
+ $response = Inertia::render('stock/returns', ['requestId' => isset($d['request_id']) ? (int) $d['request_id'] : null, 'canPrepare' => $source['can_prepare'] ?? false, 'canWithdraw' => $flow->allows($actor, 'prepare'), 'canConfirm' => $flow->allows($actor, 'confirm'), 'actorId' => $actor->id])->toResponse($r);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ }
+
+ public function sources(Request $r, WarehouseReturns $flow): JsonResponse
+ {
+ $d = $r->validate(['request_id' => 'required|integer|min:1', 'source_page' => 'sometimes|integer|min:1|max:100000']);
+ abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
+
+ return $this->response(fn () => $flow->availableDeliveries($this->actor($r), (int) $d['request_id'], (int) ($d['source_page'] ?? 1)), true);
+ }
+
+ /** @param callable():array $run */
+ private function response(callable $run,bool $readOnly=false): JsonResponse
+ {
+ try {
+ $result=$readOnly?DB::transaction(function()use($run){if(DB::getDriverName()==='pgsql'&&DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $run();}):$run();
+ return response()->json($result)->header('Cache-Control', 'no-store');
+ } catch (QueryException $e) {
+ if (($e->errorInfo[0] ?? null) === '55P03') {
+ return response()->json(['code'=>'warehouse_return_busy','message' => 'Devolução ocupada. Aguarde e repita manualmente a conferência.'], 423)->header('Cache-Control', 'no-store');
+ }
+ throw $e;
+ }
+ }
+
+ public function index(Request $r, WarehouseReturns $flow): JsonResponse
+ {
+ $d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'state' => 'sometimes|in:all,prepared,confirmed,withdrawn,rejected']);
+ abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
+
+ return $this->response(fn () => $flow->listing($this->actor($r), (int) ($d['page'] ?? 1), $d['state'] ?? 'all'), true);
+ }
+
+ public function prepare(Request $r, WarehouseReturns $flow): JsonResponse
+ {
+ $d = $r->validate(['operation_key' => 'required|uuid', 'delivery_event_id' => 'required|integer|min:1', 'line_index' => 'required|integer|min:0|max:49', 'quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', 'declared_unused' => 'required|accepted', 'evidence' => 'required|string|min:10|max:1000', 'reason' => 'required|string|min:10|max:1000']);
+ abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
+ $d['quantity_milli'] = ReceiptWorkflow::milli($d['quantity']);
+ $d['line_index'] = (int) $d['line_index'];
+ $d['delivery_event_id'] = (int) $d['delivery_event_id'];
+ $d['declared_unused'] = true;
+
+ return $this->response(fn () => $flow->prepare($this->actor($r), $d));
+ }
+
+ public function confirm(Request $r, int $id, WarehouseReturns $flow): JsonResponse
+ {
+ $d = $r->validate(['version' => 'required|integer|min:1', 'received_in_warehouse' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000']);
+ abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
+ $d['version'] = (int) $d['version'];
+ $d['received_in_warehouse'] = true;
+
+ return $this->response(fn () => $flow->confirm($this->actor($r),$id,$d));
+ }
+ public function withdraw(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'withdrawn');}
+ public function reject(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'rejected');}
+ private function close(Request $r,int $id,WarehouseReturns $flow,string $state): JsonResponse
+ {
+ $d=$r->validate(['version'=>'required|integer|min:1','reason'=>'required|string|min:10|max:1000']);abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);$d['version']=(int)$d['version'];return $this->response(fn()=>$flow->close($this->actor($r),$id,$d,$state));
+ }
+}
diff --git a/app/Http/Controllers/WorkflowTaskInboxController.php b/app/Http/Controllers/WorkflowTaskInboxController.php
new file mode 100644
index 0000000..af76d58
--- /dev/null
+++ b/app/Http/Controllers/WorkflowTaskInboxController.php
@@ -0,0 +1,55 @@
+page($request, $inbox, 'procurement');
+ }
+
+ public function people(Request $request, WorkflowTaskInbox $inbox): Response
+ {
+ return $this->page($request, $inbox, 'people');
+ }
+
+ private function page(Request $request, WorkflowTaskInbox $inbox, string $area): Response
+ {
+ $actor = $request->user('web');
+ abort_unless($actor instanceof User, 403);
+ $modules = $area === 'people' ? ['people' => 'Pessoas e contratações'] : ['purchases' => 'Compras', 'warehouse' => 'Armazém', 'supply' => 'Fornecimentos e reposição', 'finance' => 'Finanças'];
+ $filters = $request->validate(['module' => ['nullable', Rule::in(['all', ...array_keys($modules)])],
+ 'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:1000000']);
+
+ return DB::transaction(function () use ($request, $actor, $area, $inbox, $modules, $filters) {
+ if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ }
+ $all = collect($inbox->build($actor, $area));
+ $counts = [];
+ foreach ($modules as $key => $label) {
+ $counts[$key] = $all->where('module', $key)->count();
+ }
+ $rows = $all->filter(fn ($row) => (($filters['module'] ?? 'all') === 'all' || $row['module'] === $filters['module'])
+ && (($filters['search'] ?? '') === '' || mb_stripos($row['reference'].' '.$row['label'], trim($filters['search'])) !== false))->values();
+ $page = (int) ($filters['page'] ?? 1);
+ Inertia::encryptHistory();
+ $response = Inertia::render('workflow/tasks', ['area' => $area, 'modules' => $modules, 'counts' => $counts,
+ 'checkedAt' => now()->toIso8601String(), 'filters' => ['module' => $filters['module'] ?? 'all', 'search' => $filters['search'] ?? ''],
+ 'tasks' => (new LengthAwarePaginator($rows->slice(($page - 1) * 25, 25)->values(), $rows->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ });
+ }
+}
diff --git a/app/Models/Concerns/BelongsToTenant.php b/app/Models/Concerns/BelongsToTenant.php
index acf090e..a580977 100644
--- a/app/Models/Concerns/BelongsToTenant.php
+++ b/app/Models/Concerns/BelongsToTenant.php
@@ -9,7 +9,7 @@ trait BelongsToTenant
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
- $tenantId = auth()->check() ? auth()->user()->tenant_id : null;
+ $tenantId = auth()->check() ? auth()->user()->tenant_id : app(\App\Services\ReplenishmentRun::class)->tenantId();
$query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId ?? 0);
});
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index d7f4ac0..20f2360 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -15,6 +15,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
+ $this->app->singleton(\App\Services\ReplenishmentRun::class);
require_once base_path('packages/purchasing/bootstrap.php');
$package = config('common-budget.path');
require_once $package.'/bootstrap.php';
diff --git a/app/Services/AutomaticPurchases.php b/app/Services/AutomaticPurchases.php
index 244bf08..d0e9a42 100644
--- a/app/Services/AutomaticPurchases.php
+++ b/app/Services/AutomaticPurchases.php
@@ -2,10 +2,15 @@
namespace App\Services;
+use App\Http\Requests\StoreRequisitionRequest;
use App\Models\CostCenter;
+use App\Models\Equipment;
use App\Models\Requisition;
+use App\Models\SparePart;
+use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
+use App\Models\Warehouse;
use App\Models\WarehouseRequest;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
@@ -17,23 +22,70 @@ use Illuminate\Validation\ValidationException;
/** Event-driven draft preparation. Never submits, decides, orders, reserves budget or moves stock. */
class AutomaticPurchases
{
+ private function independentReviewer(User $actor, SupplyRecord $rule): bool
+ {
+ return ! in_array($actor->id, $rule->snapshot['contributors'] ?? [$rule->created_by], true);
+ }
+
+ public function canReview(User $actor, SupplyRecord $rule, string $action = 'approve'): bool
+ {
+ if ($actor->tenant_id !== $rule->tenant_id || $rule->kind !== 'auto_rule' || ! in_array($action, ['approve', 'suspend'], true)
+ || ! app(SupplyPlanning::class)->allows($actor, 'supply_review')) {
+ return false;
+ }
+ $latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
+
+ return $latest?->id === $rule->id && $rule->state === ($action === 'approve' ? 'draft' : 'approved')
+ && ($action !== 'approve' || $this->independentReviewer($actor, $rule));
+ }
+
public function eligibleRequester(User $user): bool
{
- return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && !app(OrganizationAccess::class)->maintenanceOnly($user);
+ return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
}
+
public function ready(): bool
{
return Schema::hasTable('supply_records') && app(WarehouseReservations::class)->ready();
}
+ /** @return array|null */
+ public function origin(Requisition $requisition): ?array
+ {
+ if (! $this->ready()) {
+ return null;
+ }
+ $record = SupplyRecord::query()->where('kind', 'auto_need')->whereJsonContains('data->requisition_ids', $requisition->id)->oldest('id')->first();
+
+ return $record === null ? null : ['need_id' => $record->id, 'rule_id' => $record->snapshot['rule_id'], 'item' => $record->snapshot['item'], 'warehouse' => $record->snapshot['warehouse'], 'origins' => $record->snapshot['origins'], 'created_at' => $record->created_at->toDateTimeString()];
+ }
+
+ public function requisitionChanged(User $actor, Requisition $requisition): void
+ {
+ $origin = $this->origin($requisition);
+ if ($origin !== null) {
+ $this->signal($actor, $origin['item']['id'], $origin['warehouse']['id']);
+ }
+ }
+
public function requester(User $executor, SupplyRecord $rule): User
{
- $executor = app(CurrentActor::class)->lock($executor);
+ $executor = $this->lockExecutor($executor);
$current = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
$this->check($current !== null && $current->id === $rule->id && $current->tenant_id === $executor->tenant_id && $current->state === 'approved', 'A parametrização automática deixou de estar activa.');
- $this->check(! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
- $user = User::query()->where('tenant_id', $executor->tenant_id)->lockForUpdate()->find($rule->data['requester_id']);
- $this->check($user !== null && $this->eligibleRequester($user), 'Requisitante sem competência vigente no contexto de compras internas.');
+ $this->check(app(ReplenishmentRun::class)->tenantId() !== null || ! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
+
+ return $this->configuredRequester($rule, true);
+ }
+
+ private function configuredRequester(SupplyRecord $rule, bool $lock): User
+ {
+ $query = User::query()->where('tenant_id', $rule->tenant_id)->whereKey($rule->data['requester_id']);
+ if ($lock) {
+ $query->lockForUpdate();
+ }
+ $user = $query->firstOrFail();
+ $this->check($this->eligibleRequester($user), 'Requisitante sem competência vigente no contexto de compras internas.');
$this->check(CostCenter::query()->whereKey($rule->data['cost_center_id'])->where('department_id', $user->department_id)->exists(), 'Centro de custo incompatível com o departamento actual do requisitante.');
$this->check($user->only(['id', 'name', 'department_id']) === $rule->snapshot['requester'], 'O requisitante mudou; reveja a parametrização.');
$center = CostCenter::query()->findOrFail($rule->data['cost_center_id']);
@@ -42,6 +94,30 @@ class AutomaticPurchases
return $user;
}
+ /** Inspect the same sources used by generation, without actor substitution, locks or commands.
+ * @return array{valid:bool,issues:list}
+ */
+ public function inspectRule(User $viewer, SupplyRecord $rule): array
+ {
+ abort_unless(auth()->id() === $viewer->id && $viewer->tenant_id === $rule->tenant_id && $viewer->hasFunctions(['admin']), 403);
+ try {
+ $latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
+ $this->check($latest !== null && $latest->id === $rule->id && $latest->state === 'approved', 'A regra já não está activa.');
+ $requester = $this->configuredRequester($rule, false);
+ $form = new StoreRequisitionRequest;
+ $form->setUserResolver(fn () => $requester);
+ Validator::make(['items' => [['unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]], array_intersect_key($form->rules(), array_flip(['items.*.unit_price', 'items.*.tax_rate'])), $form->messages())->validate();
+ $this->basis($rule);
+
+ return ['valid' => true, 'issues' => []];
+ } catch (ValidationException $error) {
+ return ['valid' => false, 'issues' => array_values(array_merge(...array_values($error->errors())))];
+ } catch (ModelNotFoundException) {
+ return ['valid' => false, 'issues' => ['Uma origem deixou de estar disponível; reveja a parametrização.']];
+ }
+ }
+
+ /** @param array $input */
public function save(User $actor, array $input): SupplyRecord
{
abort_unless($this->ready(), 503, 'Instalação da reserva e reposição pendente.');
@@ -59,24 +135,37 @@ class AutomaticPurchases
return $retry;
}
$old = empty($data['record_key']) ? null : app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $data['record_key'])->firstOrFail();
- $this->check($data['version'] === ($old?->version ?? 0), 'A parametrização mudou. Actualize a página.');
- $snapshot = $old?->snapshot ?? [];
- $values = $old?->data ?? [];
+ $this->check($data['version'] === ($old->version ?? 0), 'A parametrização mudou. Actualize a página.');
+ $snapshot = $old->snapshot ?? [];
+ $values = $old->data ?? [];
if ($data['action'] === 'save') {
$values = Validator::make($data['data'], ['policy_key' => 'required|uuid', 'requester_id' => 'required|integer|min:1', 'cost_center_id' => 'required|integer|min:1',
'unit_price' => 'nullable|string|regex:/^[0-9]{1,8}(\.[0-9]{1,2})?$/D|numeric|min:0.01', 'tax_rate' => 'nullable|string|numeric|min:0|max:100|regex:/^[0-9]{1,3}(\.[0-9]{1,2})?$/D',
- 'needed_days' => 'required|integer|min:0|max:366', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today'])->validate();
+ 'equipment_id' => 'nullable|integer|min:1', 'needed_days' => 'required|integer|min:0|max:366', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today'])->validate();
$basis = app(SupplySuggestions::class)->basis($values['policy_key'], today()->toDateString());
$values['unit_price'] = $values['unit_price'] ?? $basis['unit_price'];
- $values['tax_rate'] = $values['tax_rate'] ?? (string) $basis['tax_rate'];
+ $values['tax_rate'] = $values['tax_rate'] ?? $basis['tax_rate'];
$this->check($values['unit_price'] !== null, 'O acordo não fornece um preço em MZN. Registe uma estimativa válida.');
+ $this->check($values['tax_rate'] !== null, 'A empresa não tem imposto configurado. Registe uma estimativa fundamentada.');
+ $values['tax_rate'] = (string) $values['tax_rate'];
$this->check($basis['policy']['purpose'] === 'internal', 'Esta parametrização destina-se às compras internas do SGC.');
- $this->check($basis['catalogue']['purchase_type'] === 'consumable', 'Seleccione um consumível publicado; equipamento/peças exigem o seu destino técnico.');
- $user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($values['requester_id']);
+ $this->check(in_array($basis['catalogue']['purchase_type'], ['consumable', 'other', 'spare_part'], true), 'Seleccione um artigo de stock publicado. Equipamento novo e serviços seguem o seu processo próprio.');
+ $user = User::query()->where('tenant_id', $actor->tenant_id)->whereKey($values['requester_id'])->firstOrFail();
$this->check($this->eligibleRequester($user), 'Seleccione um requisitante com competência vigente em compras internas.');
$center = CostCenter::query()->where('department_id', $user->department_id)->findOrFail($values['cost_center_id']);
- $snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id,
+ $snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])),
'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']];
+ if ($basis['catalogue']['purchase_type'] === 'spare_part') {
+ $this->check(! empty($values['equipment_id']), 'Seleccione o equipamento de destino da peça.');
+ $part = SparePart::query()->where('stock_item_id', $basis['stock_item_id'])->firstOrFail();
+ app(EquipmentWorkflow::class)->linkedPart($part->id, (int) $values['equipment_id'], $user->department_id);
+ $equipment = Equipment::query()->whereKey($values['equipment_id'])->firstOrFail();
+ $values['spare_part_id'] = $part->id;
+ $snapshot['equipment'] = $equipment->only(['id', 'code', 'description', 'department_id']);
+ $snapshot['spare_part'] = $part->toArray();
+ } else {
+ $this->check(empty($values['equipment_id']), 'O equipamento na parametrização aplica-se apenas a peças. Os destinos dos pedidos são preservados na origem.');
+ }
$duplicates = app(SupplyPlanning::class)->latest('auto_rule')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id'])->whereIn('state', ['draft', 'approved']);
if ($old !== null) {
$duplicates->where('record_key', '!=', $old->record_key);
@@ -84,7 +173,7 @@ class AutomaticPurchases
$this->check(! $duplicates->exists(), 'Já existe uma parametrização neste artigo/armazém. Reveja a existente.');
} else {
$this->check($old !== null && $old->state === ($data['action'] === 'approve' ? 'draft' : 'approved'), 'Estado incompatível com a intervenção.');
- $this->check($data['action'] !== 'approve' || $snapshot['prepared_by'] !== $actor->id, 'Quem preparou não pode activar a própria parametrização.');
+ $this->check($data['action'] !== 'approve' || $this->independentReviewer($actor, $old), 'Quem preparou não pode activar a própria parametrização.');
}
$row = $this->append($actor, 'auto_rule', $old, $data['action'] === 'save' ? 'draft' : ($data['action'] === 'approve' ? 'approved' : 'suspended'), $values, $snapshot, $data['reason'], $data['operation_key'], $hash);
if ($row->state === 'approved') {
@@ -97,11 +186,17 @@ class AutomaticPurchases
});
}
+ /** @return array */
private function basis(SupplyRecord $rule): array
{
$this->check($rule->data['valid_until'] >= today()->toDateString(), 'A estimativa/parametrização automática expirou.');
$basis = app(SupplySuggestions::class)->basis($rule->data['policy_key'], today()->toDateString());
$this->check($basis['policy_version'] === $rule->snapshot['policy_version'] && $basis['item'] === $rule->snapshot['item'] && $basis['warehouse'] === $rule->snapshot['warehouse'] && $basis['catalogue'] === $rule->snapshot['catalogue'], 'A política, o catálogo ou o armazém mudou. Reveja a parametrização automática.');
+ if ($basis['catalogue']['purchase_type'] === 'spare_part') {
+ $part = app(EquipmentWorkflow::class)->linkedPart($rule->data['spare_part_id'], (int) $rule->data['equipment_id'], $rule->snapshot['requester']['department_id']);
+ $equipment = Equipment::query()->whereKey($rule->data['equipment_id'])->firstOrFail();
+ $this->check($part->toArray() === $rule->snapshot['spare_part'] && $equipment->only(['id', 'code', 'description', 'department_id']) === $rule->snapshot['equipment'], 'O destino técnico mudou. Reveja a parametrização da peça.');
+ }
return $basis;
}
@@ -112,9 +207,20 @@ class AutomaticPurchases
if (! $this->ready()) {
return;
}
+ if (app(ReplenishmentQueue::class)->ready()) {
+ $id = app(ReplenishmentQueue::class)->enqueue($actor, $item, $warehouse);
+ DB::afterCommit(fn () => app(ReplenishmentQueue::class)->interactive($actor, $id));
+
+ return;
+ }
DB::afterCommit(fn () => $this->evaluate($actor, $item, $warehouse));
}
+ public function lockExecutor(User $actor): User
+ {
+ return app(ReplenishmentRun::class)->tenantId() === null ? app(CurrentActor::class)->lock($actor) : app(ReplenishmentRun::class)->lockResponsible($actor);
+ }
+
public function evaluate(User $actor, int $item, int $warehouse): void
{
if (! $this->ready()) {
@@ -122,15 +228,19 @@ class AutomaticPurchases
}
DB::transaction(function () use ($actor, $item, $warehouse) {
app(ConflictGuard::class)->lockCompany($actor);
- $actor = app(CurrentActor::class)->lock($actor);
- abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
+ $actor = $this->lockExecutor($actor);
+ abort_if(app(ReplenishmentRun::class)->tenantId() === null && app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$planning = app(SupplyPlanning::class);
- $policy = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
$rule = $planning->latest('auto_rule')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
+ $policies = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->where('data->purpose', 'internal');
+ if ($rule !== null) {
+ $policies->where('record_key', $rule->data['policy_key']);
+ }
+ $policy = $policies->latest('id')->first();
$old = $planning->latest('auto_need')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
$origins = [];
$unreserved = 0;
- foreach (WarehouseRequest::query()->where('warehouse_id', $warehouse)->get() as $request) {
+ foreach (WarehouseRequest::query()->where('work_context_key', 'procurement.internal')->where('warehouse_id', $warehouse)->get() as $request) {
$p = app(WarehouseFulfilment::class)->progress($request);
if (! in_array($p['state'], ['submitted', 'authorized', 'partial'], true)) {
continue;
@@ -142,14 +252,14 @@ class AutomaticPurchases
}
}
}
- if ($policy === null && $unreserved === 0 && $old === null) {
+ if ($policy === null && $origins === [] && $old === null) {
return;
}
$purchases = $old?->data['purchases'] ?? [];
- $snapshot = ['item' => ['id' => $item], 'warehouse' => ['id' => $warehouse], 'origins' => $origins, 'rule_id' => $rule?->id];
+ $snapshot = ['item' => StockItem::query()->whereKey($item)->firstOrFail()->only(['id', 'code', 'name', 'unit']), 'warehouse' => Warehouse::query()->whereKey($warehouse)->firstOrFail()->only(['id', 'code', 'name']), 'origins' => $origins, 'rule_id' => $rule?->id];
$state = 'pending';
$message = 'Parametrize requisitante, centro de custo e reposição por artigo/armazém.';
- $values = ['purchases' => $purchases, 'message' => $message];
+ $values = ['purchases' => $purchases, 'requisition_ids' => array_column($purchases, 'id'), 'message' => $message];
try {
$free = app(WarehouseReservations::class)->free($item, $warehouse);
$minimum = $policy?->data['minimum_stock_milli'] ?? 0;
@@ -162,20 +272,31 @@ class AutomaticPurchases
$this->check($rule !== null, $message);
$requester = $this->requester($actor, $rule);
$basis = $this->basis($rule);
+ if ($basis['catalogue']['purchase_type'] === 'spare_part') {
+ foreach ($origins as $origin) {
+ $this->check(($origin['equipment']['id'] ?? null) === (int) $rule->data['equipment_id'], 'O equipamento do pedido não coincide com o destino técnico da parametrização. Reconciliar os destinos antes de gerar a compra.');
+ }
+ }
$coverage = 0;
$blocked = false;
- foreach ($purchases as $purchase) {
- $r = Requisition::query()->find($purchase['id']);
- $this->check($r !== null, 'A requisição de reposição anterior requer reconciliação.');
+ $tracked = $purchases;
+ foreach ($planning->latest('suggestion')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->whereIn('state', ['converted', 'closed'])->get() as $suggestion) {
+ if (! in_array($suggestion->data['requisition_id'], array_column($tracked, 'id'), true)) {
+ $tracked[] = ['id' => $suggestion->data['requisition_id'], 'rule_id' => null];
+ }
+ }
+ foreach ($tracked as $purchase) {
+ $r = Requisition::query()->whereKey($purchase['id'])->firstOrFail();
if ($r->status === 'rejected') {
if ($purchase['rule_id'] === $rule->id) {
$blocked = true;
}
-continue;
+ continue;
}
$this->check(in_array($r->status, ['draft', 'pending', 'approved'], true), 'Rever o estado da reposição anterior antes de gerar nova compra.');
$r->load('items');
+ $this->check($r->items->count() === 1, 'Uma reposição anterior foi repartida ou alterada; reconcilie as linhas antes de gerar nova compra.');
$line = $r->items->sole();
$this->check($line->catalogue_entry_id === $basis['catalogue']['id'] && ($line->catalogue_snapshot['version'] ?? null) === $basis['catalogue']['version'], 'O rascunho automático anterior mudou de artigo; reconcilie a necessidade.');
$received = 0;
@@ -185,11 +306,13 @@ continue;
$received += (int) $receipt->accepted_milli;
}
}
- $coverage += max(0, ReceiptWorkflow::milli($line->quantity) - $received);
+ $orderLines = DB::table('purchase_order_items')->where('tenant_id', $actor->tenant_id)->where('requisition_item_id', $line->id)->get(['quantity']);
+ $expected = $orderLines->isEmpty() ? ReceiptWorkflow::milli($line->quantity) : $orderLines->sum(fn ($ordered) => ReceiptWorkflow::milli($ordered->quantity));
+ $coverage += max(0, $expected - $received);
}
$this->check(! $blocked, 'A reposição foi rejeitada. Rever e activar nova versão da parametrização antes de tentar novamente.');
// A confirmed outside delivery also covers the need; never double-count an automatic order.
- $automaticIds = array_column($purchases, 'id');
+ $automaticIds = array_column($tracked, 'id');
foreach ($planning->latest('delivery')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'active')->get() as $delivery) {
$order = DB::table('purchase_orders')->where('id', $delivery->purchase_order_id)->first();
if ($order !== null && ! in_array($order->requisition_id, $automaticIds, true)) {
@@ -203,13 +326,19 @@ continue;
$values['message'] = 'Reposição efectiva já em curso; não foi duplicada.';
} else {
$pack = $basis['policy']['pack_milli'];
- $quantity = (int) (ceil(max($demand, $basis['policy']['minimum_milli']) / $pack) * $pack);
+ $quantity = intdiv(max($demand, $basis['policy']['minimum_milli']) + $pack - 1, $pack) * $pack;
$this->check($quantity <= 1000000000 && $quantity + $free - $unreserved + $coverage <= $basis['policy']['maximum_milli'], 'A embalagem ou quantidade mínima excede o máximo aprovado. Rever a política.');
$decimal = intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT);
- $r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, ['cost_center_id' => $rule->data['cost_center_id'], 'priority' => 'normal', 'needed_by' => today()->addDays($rule->data['needed_days'])->toDateString(),
+ $payload = ['cost_center_id' => $rule->data['cost_center_id'], 'priority' => 'normal', 'needed_by' => today()->addDays($rule->data['needed_days'])->toDateString(),
'justification' => 'Reposição automática do armazém '.$basis['warehouse']['code'].', artigo '.$basis['item']['code'].'. Origem e utilização preservadas na necessidade de reposição.',
- 'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]]);
+ 'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'purchase_type' => $basis['catalogue']['purchase_type'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate'],
+ ...($basis['catalogue']['purchase_type'] === 'spare_part' ? ['equipment_id' => $rule->data['equipment_id'], 'spare_part_id' => $rule->data['spare_part_id']] : [])]]];
+ $form = new StoreRequisitionRequest;
+ $form->setUserResolver(fn () => $requester);
+ $payload = Validator::make($payload, $form->rules(), $form->messages())->validate();
+ $r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, $payload);
$values['purchases'][] = ['id' => $r->id, 'number' => $r->number, 'quantity_milli' => $quantity, 'rule_id' => $rule->id];
+ $values['requisition_ids'][] = $r->id;
$state = 'created';
$values['message'] = 'Rascunho criado; o requisitante deve conferir e submeter às aprovações habituais.';
}
@@ -222,21 +351,24 @@ continue;
if ($old !== null && $old->state === $state && $old->data === $values && $old->snapshot === $snapshot) {
return;
}
- $this->append($actor, 'auto_need', $old, $state, $values, $snapshot, 'Avaliação automática após alteração da disponibilidade ou parametrização.');
+ $this->append($actor, 'auto_need', $old, $state, $values, $snapshot, app(ReplenishmentRun::class)->tenantId() === null ? 'Avaliação automática após alteração da disponibilidade ou parametrização.' : 'Reavaliação periódica ou recuperação da reposição, sem decisão automática de compra.');
});
}
+ /** @param array $data
+ * @param array $snapshot
+ */
private function append(User $actor, string $kind, ?SupplyRecord $old, string $state, array $data, array $snapshot, string $reason, ?string $key = null, ?string $hash = null): SupplyRecord
{
- $row = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old?->record_key ?? (string) Str::uuid(), 'version' => ($old?->version ?? 0) + 1, 'state' => $state,
+ $row = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old->record_key ?? (string) Str::uuid(), 'version' => ($old->version ?? 0) + 1, 'state' => $state,
'stock_item_id' => $snapshot['item']['id'], 'warehouse_id' => $snapshot['warehouse']['id'], 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason,
'operation_key' => $key ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode([$state, $data, $snapshot], JSON_THROW_ON_ERROR)), 'created_at' => now()]);
- app(RequisitionWorkflow::class)->audit($actor,$kind.'.'.$state,null,null,['record_id' => $row->id, 'record_key' => $row->record_key, 'version' => $row->version, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason]);
+ app(RequisitionWorkflow::class)->audit($actor, $kind.'.'.$state, null, null, ['record_id' => $row->id, 'record_key' => $row->record_key, 'version' => $row->version, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason]);
return $row;
}
- private function check(bool $valid,string $message): void
+ private function check(bool $valid, string $message): void
{
if (! $valid) {
throw ValidationException::withMessages(['replenishment' => $message]);
diff --git a/app/Services/DocumentaryQualificationWorkflow.php b/app/Services/DocumentaryQualificationWorkflow.php
index 8f1ebf5..7c7e379 100644
--- a/app/Services/DocumentaryQualificationWorkflow.php
+++ b/app/Services/DocumentaryQualificationWorkflow.php
@@ -24,6 +24,7 @@ class DocumentaryQualificationWorkflow
/** Call after the company/actor locks; coordinates with award and quote transactions. */
private function process(User $actor, int $requisitionId): Requisition
{
+ abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($requisitionId);
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $request->id)->exists()) {
$this->invalid('qualification', 'Registe a análise num pedido aprovado, antes da adjudicação. As versões anteriores continuam disponíveis.');
@@ -36,6 +37,36 @@ class DocumentaryQualificationWorkflow
return $request;
}
+ private function canParticipate(User $actor, Requisition $request): bool
+ {
+ return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
+ && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
+ && Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
+ && ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
+ && ! app(ConflictGuard::class)->participationBlocked($actor, $request);
+ }
+
+ public function canDefine(User $actor, Requisition $request): bool
+ {
+ return $actor->hasFunctions([PurchasingRole::Procurement]) && $this->canParticipate($actor, $request)
+ && Quotation::query()->where('requisition_id', $request->id)->exists();
+ }
+
+ public function currentSet(Requisition $request, QualificationSet $set): bool
+ {
+ $latest = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first();
+ $opening = app(ProposalOpeningWorkflow::class);
+
+ return $set->tenant_id === $request->tenant_id && $set->requisition_id === $request->id && $latest?->id === $set->id
+ && $set->basis_hash === $opening->basis($opening->proposals($request));
+ }
+
+ public function canAssess(User $actor, Requisition $request, QualificationSet $set): bool
+ {
+ return $actor->id !== $request->requester_id && app(ProposalComparison::class)->eligibleRole($actor, $request)
+ && $this->canParticipate($actor, $request) && $this->currentSet($request, $set);
+ }
+
/** @param array $data */
public function define(User $actor, Requisition $request, array $data): QualificationSet
{
@@ -80,11 +111,10 @@ class DocumentaryQualificationWorkflow
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$request = $this->process($actor, $set->requisition_id);
+ abort_unless(app(ProposalComparison::class)->eligibleRole($actor, $request), 403);
abort_if($actor->id === $request->requester_id, 403);
$set = QualificationSet::query()->lockForUpdate()->findOrFail($set->id);
- $latestSet = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->firstOrFail();
- $opening = app(ProposalOpeningWorkflow::class);
- if ($set->id !== $latestSet->id || $set->basis_hash !== $opening->basis($opening->proposals($request))) {
+ if (! $this->currentSet($request, $set)) {
$this->invalid('qualification', 'Os requisitos ou a base mudaram. As análises anteriores são históricas; registe uma versão actual dos requisitos.');
}
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
diff --git a/app/Services/ExternalPaymentWorkflow.php b/app/Services/ExternalPaymentWorkflow.php
index 617c678..7a75adf 100644
--- a/app/Services/ExternalPaymentWorkflow.php
+++ b/app/Services/ExternalPaymentWorkflow.php
@@ -15,12 +15,47 @@ use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class ExternalPaymentWorkflow
{
private function actor(User $user, int $tenantId): void
{
- abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]), 403);
+ abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user), 403);
+ }
+
+ private function reviewerBaseEligible(User $user, ExternalPayment $payment): bool
+ {
+ return $user->tenant_id === $payment->tenant_id && $payment->created_by !== $user->id
+ && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
+ }
+
+ /** The confirmation still rechecks bank, balance, author and evidence in its transaction. */
+ public function reviewerEligible(User $user, ExternalPayment $payment): bool
+ {
+ if ($payment->status !== 'pending' || ! $this->reviewerBaseEligible($user, $payment)) {
+ return false;
+ }
+ $invoice = Invoice::query()->whereKey($payment->invoice_id)->first();
+ $order = $invoice === null ? null : PurchaseOrder::query()->whereKey($invoice->purchase_order_id)->first();
+ if ($invoice === null || $order === null) {
+ return false;
+ }
+ $request = Requisition::query()->visibleTo($user)->whereKey($order->requisition_id)->first();
+ if ($request === null || app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $user)) {
+ return false;
+ }
+ try {
+ app(SupplierPayables::class)->assertHistoricalReview($user, $invoice);
+ } catch (HttpExceptionInterface $error) {
+ if (! in_array($error->getStatusCode(), [403, 409], true)) {
+ throw $error;
+ }
+
+ return false;
+ }
+
+ return true;
}
private function invalid(string $message): never
@@ -100,7 +135,7 @@ class ExternalPaymentWorkflow
public function review(User $user, ExternalPayment $payment, string $decision, string $reason): void
{
$this->actor($user, $payment->tenant_id);
- abort_if($payment->created_by === $user->id, 403);
+ abort_unless($this->reviewerBaseEligible($user, $payment), 403);
if (! in_array($decision, ['confirmed', 'rejected']) || trim($reason) === '') {
$this->invalid('Decisão e fundamento obrigatórios.');
}
diff --git a/app/Services/FormalAdmission.php b/app/Services/FormalAdmission.php
index 5cbc825..454eca7 100644
--- a/app/Services/FormalAdmission.php
+++ b/app/Services/FormalAdmission.php
@@ -33,6 +33,7 @@ class FormalAdmission
private function process(User $actor, int $id): Requisition
{
+ abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
$this->invalid('O regime e as decisões só podem ser registados num pedido aprovado, antes da adjudicação.');
@@ -174,7 +175,40 @@ class FormalAdmission
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
{
- return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
+ return $actor->tenant_id === $request->tenant_id && $adoption->tenant_id === $request->tenant_id && $decision->tenant_id === $request->tenant_id
+ && $adoption->requisition_id === $request->id && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
+ && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
+ }
+
+ public function canParticipate(User $actor, Requisition $request): bool
+ {
+ return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
+ && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
+ && Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
+ && ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
+ && ! PurchaseOrder::query()->where('requisition_id', $request->id)->exists()
+ && ! app(ConflictGuard::class)->participationBlocked($actor, $request);
+ }
+
+ /** Same immutable basis check used before a decision is recorded. */
+ public function currentReviewBasis(Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
+ {
+ if ($adoption->requisition_id !== $request->id || $decision->admission_adoption_id !== $adoption->id) {
+ return false;
+ }
+ $latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
+ $basis = $this->basis($request, $decision->quotation_id);
+
+ return $latest?->id === $decision->id && $basis !== null && $basis['hash'] === $decision->basis_hash;
+ }
+
+ public function canReview(User $actor, Requisition $request, AdmissionDecision $decision): bool
+ {
+ $adoption = $this->adoption($request);
+
+ return $adoption !== null && $this->eligible($actor, $request, $adoption, $decision) && $this->canParticipate($actor, $request)
+ && $this->currentReviewBasis($request, $adoption, $decision) && $this->status($decision, $adoption) === 'pending'
+ && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->exists();
}
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
@@ -189,9 +223,7 @@ class FormalAdmission
$request = $this->process($actor, $original->requisition_id);
$adoption = $this->adoption($request);
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $decision), 403);
- $latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
- $basis = $this->basis($request, $decision->quotation_id);
- if ($decision->admission_adoption_id !== $adoption->id || $latest?->id !== $decision->id || ! $basis || $basis['hash'] !== $decision->basis_hash) {
+ if (! $this->currentReviewBasis($request, $adoption, $decision)) {
$this->invalid('A política do processo, tentativa ou base mudou. Registe outra tentativa e novas conferências.');
}
$existing = AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->first();
diff --git a/app/Services/FormalRecommendation.php b/app/Services/FormalRecommendation.php
index cb1d42e..0e22ab2 100644
--- a/app/Services/FormalRecommendation.php
+++ b/app/Services/FormalRecommendation.php
@@ -34,6 +34,7 @@ class FormalRecommendation
private function process(User $actor, int $id): Requisition
{
+ abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
$this->invalid('A recomendação só pode ser preparada num pedido aprovado, antes de adjudicar.');
@@ -152,7 +153,40 @@ class FormalRecommendation
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
{
- return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
+ return $actor->tenant_id === $request->tenant_id && $adoption->tenant_id === $request->tenant_id && $attempt->tenant_id === $request->tenant_id
+ && $adoption->requisition_id === $request->id && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
+ && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
+ }
+
+ public function canParticipate(User $actor, Requisition $request): bool
+ {
+ return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
+ && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
+ && Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
+ && ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
+ && ! PurchaseOrder::query()->where('requisition_id', $request->id)->exists()
+ && ! app(ConflictGuard::class)->participationBlocked($actor, $request);
+ }
+
+ /** Same immutable basis check used before a decision is recorded. */
+ public function currentReviewBasis(Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
+ {
+ if ($adoption->requisition_id !== $request->id || $attempt->recommendation_adoption_id !== $adoption->id) {
+ return false;
+ }
+ $latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
+ $basis = $this->basis($request);
+
+ return $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
+ }
+
+ public function canReview(User $actor, Requisition $request, RecommendationAttempt $attempt): bool
+ {
+ $adoption = $this->adoption($request);
+
+ return $adoption !== null && $this->eligible($actor, $request, $adoption, $attempt) && $this->canParticipate($actor, $request)
+ && $this->currentReviewBasis($request, $adoption, $attempt) && $this->status($attempt, $adoption) === 'pending'
+ && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->exists();
}
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
@@ -167,9 +201,7 @@ class FormalRecommendation
$request = $this->process($actor, $original->requisition_id);
$adoption = $this->adoption($request);
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $attempt), 403);
- $latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
- $basis = $this->basis($request);
- if ($attempt->recommendation_adoption_id !== $adoption->id || $latest?->id !== $attempt->id || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash) {
+ if (! $this->currentReviewBasis($request, $adoption, $attempt)) {
$this->invalid('A base, adopção ou tentativa mudou. Prepare outra recomendação e novas conferências.');
}
$existing = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->first();
diff --git a/app/Services/InternalRequisitionPort.php b/app/Services/InternalRequisitionPort.php
index bf98fd7..6b28b9c 100644
--- a/app/Services/InternalRequisitionPort.php
+++ b/app/Services/InternalRequisitionPort.php
@@ -30,6 +30,12 @@ final class InternalRequisitionPort implements RequisitionPort
{
return DB::transaction(function () use ($work) {
app(ConflictGuard::class)->lockCompany($this->actor);
+ if (app(ReplenishmentRun::class)->tenantId() !== null) {
+ abort_unless($this->automaticRule !== null, 403);
+ $this->actor = app(ReplenishmentRun::class)->lockResponsible($this->actor);
+
+ return $work();
+ }
$this->actor = app(CurrentActor::class)->lock($this->actor);
abort_unless(app(OrganizationAccess::class)->permissions($this->actor) !== [], 403);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($this->actor), 403);
diff --git a/app/Services/InternalSupplyTrace.php b/app/Services/InternalSupplyTrace.php
new file mode 100644
index 0000000..e9a2bec
--- /dev/null
+++ b/app/Services/InternalSupplyTrace.php
@@ -0,0 +1,98 @@
+ $progress
+ * @return array
+ */
+ public function forRequest(User $actor, WarehouseRequest $request, array $progress): array
+ {
+ abort_unless(auth()->id() === $actor->id && app(WarehouseWorkdesk::class)->canView($actor, $request), 403);
+ if (! Schema::hasTable('supply_records')) {
+ return ['groups' => [], 'ready' => false];
+ }
+ // Last version that actually mentioned this request: future shared purchases
+ // must not be retroactively attributed to a request already fulfilled.
+ $matched = SupplyRecord::query()->where('kind', 'auto_need')->where('warehouse_id', $request->warehouse_id);
+ if (DB::getDriverName() === 'pgsql') {
+ $matched->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(supply_records.snapshot::jsonb->'origins') o WHERE o->>'warehouse_request_id' = ?)", [(string) $request->id]);
+ } else {
+ $matched->whereRaw("EXISTS (SELECT 1 FROM json_each(supply_records.snapshot, '$.origins') o WHERE json_extract(o.value, '$.warehouse_request_id') = ?)", [$request->id]);
+ }
+ $records = SupplyRecord::query()->whereIn('id', $matched->selectRaw('MAX(id)')->groupBy('record_key'))->orderBy('stock_item_id')->get();
+ $current = app(SupplyPlanning::class)->latest('auto_need')->whereIn('record_key', $records->pluck('record_key'))->get()->keyBy('record_key');
+ $corrections = app(InventoryReversals::class)->corrections('receipt');
+ $groups = [];
+ foreach ($records as $record) {
+ $indexes = [];
+ foreach ($record->snapshot['origins'] as $origin) {
+ $index = $origin['line_index'];
+ if ($origin['warehouse_request_id'] === $request->id && isset($request->snapshot['lines'][$index])
+ && $request->snapshot['lines'][$index]['item']['id'] === $record->stock_item_id) {
+ $indexes[] = $index;
+ }
+ }
+ if ($indexes === []) {
+ continue;
+ }
+ $latest = $current->get($record->record_key) ?? $record;
+ $ids = array_values(array_unique($record->data['requisition_ids'] ?? []));
+ $purchases = Requisition::query()->visibleTo($actor)->whereIn('id', $ids)->with('steps')->orderBy('id')->get();
+ $rows = [];
+ foreach ($purchases as $purchase) {
+ $orders = [];
+ foreach (PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $purchase->id)->orderBy('id')->get() as $order) {
+ $receipts = [];
+ foreach (DB::table('goods_receipt_items as i')->join('goods_receipts as g', 'g.id', '=', 'i.goods_receipt_id')
+ ->where('i.tenant_id', $actor->tenant_id)->where('g.tenant_id', $actor->tenant_id)->where('g.purchase_order_id', $order->id)
+ ->where('g.warehouse_id', $request->warehouse_id)->where('i.stock_item_id', $record->stock_item_id)
+ ->orderBy('i.id')->get(['i.id', 'g.id as receipt_id', 'g.received_on', 'i.accepted_milli']) as $receipt) {
+ $receipts[] = ['id' => $receipt->receipt_id, 'item_id' => $receipt->id, 'received_on' => $receipt->received_on,
+ 'accepted_milli' => (int) $receipt->accepted_milli, 'reversed' => isset($corrections[$receipt->id])];
+ }
+ $orders[] = ['id' => $order->id, 'number' => $order->number,
+ 'href' => $actor->hasFunctions(['admin', 'procurement', 'finance', 'department_head']) ? '/ordens/'.$order->id : null,
+ 'receipts' => $receipts, 'effective_received_milli' => array_sum(array_map(fn ($row) => $row['reversed'] ? 0 : $row['accepted_milli'], $receipts))];
+ }
+ $next = $purchase->status === 'pending' ? $purchase->steps->firstWhere('decision', 'pending') : null;
+ $rows[] = ['id' => $purchase->id, 'number' => $purchase->number, 'status' => $purchase->status, 'href' => '/requisicoes/'.$purchase->id,
+ 'next_task' => $next === null ? null : (OrganizationAccess::PERMISSIONS[$next->role] ?? $next->role), 'orders' => $orders];
+ }
+ $unreserved = array_sum(array_map(fn ($index) => max(0, $progress['remaining'][$index] - $progress['reserved'][$index]), $indexes));
+ $groups[] = ['item' => $request->snapshot['lines'][$indexes[0]]['item'], 'line_indexes' => array_values(array_unique($indexes)),
+ 'origin_need_id' => $record->id, 'current_need_id' => $latest->id, 'need_state' => $latest->state,
+ 'message' => $latest->data['message'] ?? '', 'unreserved_milli' => $unreserved,
+ 'purchases' => $rows, 'restricted' => $purchases->count() !== count($ids)];
+ }
+
+ return ['ready' => true, 'groups' => $groups];
+ }
+
+ /** @return array|null */
+ public function purchaseOrigin(User $actor, Requisition $purchase): ?array
+ {
+ $origin = app(AutomaticPurchases::class)->origin($purchase);
+ if ($origin === null) {
+ return null;
+ }
+ $sources = WarehouseRequest::query()->whereIn('id', array_column($origin['origins'], 'warehouse_request_id'))->get()->keyBy('id');
+ $origin['origins'] = array_map(function ($row) use ($sources, $actor) {
+ $request = $sources->get($row['warehouse_request_id']);
+
+ return [...$row, 'href' => $request !== null && app(WarehouseWorkdesk::class)->canView($actor, $request) ? '/stock/requisicoes/'.$request->id : null];
+ }, $origin['origins']);
+
+ return $origin;
+ }
+}
diff --git a/app/Services/InventoryReversals.php b/app/Services/InventoryReversals.php
index eb1e426..66d6d59 100644
--- a/app/Services/InventoryReversals.php
+++ b/app/Services/InventoryReversals.php
@@ -91,6 +91,8 @@ class InventoryReversals
$receiptSource = null;
if ($data['source_kind'] === 'operation') {
$operation = StockOperation::query()->whereKey($data['source_operation_id'])->firstOrFail();
+ $sourceMovementIds=StockMovement::query()->where('stock_operation_id',$operation->id)->pluck('id');
+ $this->check(!StockMovement::query()->where('movement_type','workshop_return_in')->whereIn('snapshot->delivery_movement_id',$sourceMovementIds)->exists(),'action','A entrega já tem devolução física de excedente. Preserve o original e confira a correcção própria.');
$this->check(in_array($operation->kind, ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'], true), 'data.source_operation_id', 'Seleccione uma operação física; contagens e outros estornos não são estornados por este percurso.');
$this->check(! $this->operationReversed($operation->id), 'action', 'Esta operação já tem estorno integral.');
$movements = StockMovement::query()->where('stock_operation_id', $operation->id)->orderBy('id')->get();
diff --git a/app/Services/InvoiceWorkflow.php b/app/Services/InvoiceWorkflow.php
index 0718615..39b37ae 100644
--- a/app/Services/InvoiceWorkflow.php
+++ b/app/Services/InvoiceWorkflow.php
@@ -19,7 +19,28 @@ class InvoiceWorkflow
{
private function access(User $user): void
{
- abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403);
+ abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user), 403);
+ }
+
+ private function reviewerBaseEligible(User $user, Invoice $invoice): bool
+ {
+ return $user->tenant_id === $invoice->tenant_id && $invoice->created_by !== $user->id
+ && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
+ }
+
+ /** Pending intervention, not a promise of a matching invoice or budget availability. */
+ public function reviewerEligible(User $user, Invoice $invoice): bool
+ {
+ if ($invoice->status !== 'pending' || ! $this->reviewerBaseEligible($user, $invoice)) {
+ return false;
+ }
+ $order = PurchaseOrder::query()->whereKey($invoice->purchase_order_id)->first();
+ if ($order === null) {
+ return false;
+ }
+ $request = Requisition::query()->visibleTo($user)->whereKey($order->requisition_id)->first();
+
+ return $request !== null && ! app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $user);
}
private function invalid(string $message): never
@@ -49,7 +70,10 @@ class InvoiceWorkflow
if ($quantity <= 0 || (int) $previous + $quantity > (int) $accepted || (int) $previous + $quantity > ReceiptWorkflow::milli($source->quantity)) {
$this->invalid('Quantidade superior à recepção aceite ainda não facturada. Serviços também exigem aceitação.');
}
- if (BigDecimal::of((string) $input['unit_price'])->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $input['tax_rate'])->compareTo((string) $source->tax_rate) !== 0 || trim((string) $input['unit']) !== $source->unit) {
+ $unit = array_key_exists('unit', $input) ? $input['unit'] : $source->unit;
+ $unitPrice = array_key_exists('unit_price', $input) ? $input['unit_price'] : (string) BigDecimal::of($source->unit_price_minor)->dividedBy(100, 2);
+ $taxRate = array_key_exists('tax_rate', $input) ? $input['tax_rate'] : $source->tax_rate;
+ if (BigDecimal::of((string) $unitPrice)->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $taxRate)->compareTo((string) $source->tax_rate) !== 0 || trim((string) $unit) !== $source->unit) {
$this->invalid('Preço, unidade e IVA devem coincidir exactamente com a ordem. Não há tolerância ou excepção configurada.');
}
// Cumulative rounding ensures partial invoices sum exactly to the frozen order.
@@ -75,6 +99,7 @@ class InvoiceWorkflow
return DB::transaction(function () use ($user, $order, $data) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
+ $this->access($user);
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
$request = Requisition::query()->findOrFail($order->requisition_id);
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
@@ -104,10 +129,11 @@ class InvoiceWorkflow
public function validate(User $user, Invoice $invoice): void
{
$this->access($user);
- abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403);
+ abort_unless($this->reviewerBaseEligible($user, $invoice), 403);
DB::transaction(function () use ($user, $invoice) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
+ $this->access($user);
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
if ($invoice->status === 'validated') {
return;
diff --git a/app/Services/OrganizationAccess.php b/app/Services/OrganizationAccess.php
index b42ae3e..17faa66 100644
--- a/app/Services/OrganizationAccess.php
+++ b/app/Services/OrganizationAccess.php
@@ -10,6 +10,9 @@ use Illuminate\Support\Facades\Schema;
class OrganizationAccess
{
public const PERMISSIONS = [
+ 'warehouse_return_read' => 'Consultar devoluções de excedentes de entregas internas',
+ 'warehouse_return_prepare' => 'Declarar excedente não consumido de entrega interna própria',
+ 'warehouse_return_confirm' => 'Conferir ou recusar recepção independente do excedente no armazém (exige função de armazém)',
'warehouse_request_review' => 'Autorizar ou rejeitar requisições internas ao armazém do departamento, de forma independente',
'warehouse_request_deliver' => 'Entregar material de requisições internas autorizadas (exige função de armazém)',
'warehouse_request_prepare' => 'Preparar requisições próprias ao armazém com destino de utilização',
@@ -70,6 +73,18 @@ class OrganizationAccess
if (! $this->active($user->tenant_id)) {
return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
}
+
+ return $this->configuredPermissions($user, $context);
+ }
+
+ /** Project the saved profiles without activating them or granting authority.
+ * @return list
+ */
+ public function configuredPermissions(User $user, ?string $context = null): array
+ {
+ if ($user->tenant_id === null || ! Schema::hasTable('organization_profiles') || ! Schema::hasTable('organization_memberships')) {
+ return [];
+ }
$contextKey = $context;
if ($contextKey === null && app()->bound('request') && request()->hasSession() && WorkContext::area(request()->path()) !== null && app(WorkContext::class)->enforced($user)) {
$contextKey = app(WorkContext::class)->active(request());
@@ -108,6 +123,15 @@ class OrganizationAccess
if (! $this->active($user->tenant_id)) {
return false;
}
+
+ return $this->configuredMaintenanceOnly($user);
+ }
+
+ public function configuredMaintenanceOnly(User $user): bool
+ {
+ if ($user->tenant_id === null || ! Schema::hasTable('organization_memberships')) {
+ return false;
+ }
$valid = DB::table('organization_memberships')->where('tenant_id', $user->tenant_id)->where('user_id', $user->id)->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
return (clone $valid)->where('maintenance', true)->exists() && ! (clone $valid)->where('maintenance', false)->exists();
diff --git a/app/Services/OrganizationActivationPreview.php b/app/Services/OrganizationActivationPreview.php
new file mode 100644
index 0000000..0ef9bfc
--- /dev/null
+++ b/app/Services/OrganizationActivationPreview.php
@@ -0,0 +1,47 @@
+ */
+ public function build(User $actor): array
+ {
+ $configuration = app(OrganizationConfiguration::class);
+ $actor = $configuration->administrator($actor);
+ $ready = Schema::hasTable('organization_access') && Schema::hasTable('organization_profiles') && Schema::hasTable('organization_memberships');
+ if (! $ready) {
+ return ['ready' => false, 'users' => []];
+ }
+ $access = app(OrganizationAccess::class);
+ $context = app(WorkContext::class);
+ $company = Tenant::query()->findOrFail($actor->tenant_id);
+ $admins = $configuration->permanentAdministrators($actor->tenant_id);
+ $active = $access->active($actor->tenant_id);
+ $users = [];
+ foreach (User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->orderBy('id')->get() as $user) {
+ $current = $access->permissions($user);
+ $configured = $access->configuredPermissions($user);
+ $enforced = $context->enforced($user);
+ $users[] = ['id' => $user->id, 'name' => $user->name, 'current_permissions' => $current, 'configured_permissions' => $configured,
+ 'added' => array_values(array_diff($configured, $current)), 'removed' => array_values(array_diff($current, $configured)),
+ 'current_can_request' => $access->canRequest($user), 'configured_can_request' => in_array('requester', $configured, true),
+ 'configured_maintenance_only' => $access->configuredMaintenanceOnly($user), 'permanent_admin' => in_array($user->id, $admins, true),
+ 'current_contexts' => $enforced ? $context->available($user) : null,
+ 'configured_contexts' => $enforced ? $context->configuredAvailable($user) : null,
+ 'loses_access' => $current !== [] && $configured === []];
+ }
+
+ return ['ready' => true, 'active' => $active, 'version' => DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->value('version') ?? 1,
+ 'checked_at' => now()->toIso8601String(), 'users' => $users, 'permanent_administrators' => count($admins),
+ 'provisional_rules' => $company->rules_provisional, 'can_activate' => ! $active && count($admins) >= 2 && ! $company->rules_provisional,
+ 'losing_access' => count(array_filter($users, fn ($row) => $row['loses_access'])),
+ 'context_labels' => array_map(fn ($option) => $option['areaLabel'].' · '.$option['label'], WorkContext::OPTIONS)];
+ }
+}
diff --git a/app/Services/OrganizationConfiguration.php b/app/Services/OrganizationConfiguration.php
index b335a54..132cb4b 100644
--- a/app/Services/OrganizationConfiguration.php
+++ b/app/Services/OrganizationConfiguration.php
@@ -161,16 +161,25 @@ class OrganizationConfiguration
private function twoAdministrators(int $tenant): void
{
- $count = 0;
+ if (count($this->permanentAdministrators($tenant)) < 2) {
+ throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
+ }
+ }
+
+ /** Same permanent administrator rule for activation, updates and their preview.
+ * @return list
+ */
+ public function permanentAdministrators(int $tenant): array
+ {
+ $ids = [];
foreach (User::query()->where('tenant_id', $tenant)->get() as $user) {
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $tenant)->where('p.tenant_id', $tenant)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions');
if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
- $count++;
+ $ids[] = $user->id;
}
}
- if ($count < 2) {
- throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
- }
+
+ return $ids;
}
public function initialise(User $first, User $second): void
diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php
index d0c11a4..6caf572 100644
--- a/app/Services/PurchaseWorkflow.php
+++ b/app/Services/PurchaseWorkflow.php
@@ -28,6 +28,7 @@ class PurchaseWorkflow
return DB::transaction(function () use ($user, $requisition, $data) {
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
+ abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($user->hasFunctions([PurchasingRole::Procurement]) && $user->id !== $requisition->requester_id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
@@ -64,7 +65,8 @@ class PurchaseWorkflow
public function canDecide(User $user, PurchaseAward $award, Requisition $requisition): bool
{
- if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || in_array($user->id, [$requisition->requester_id, $award->created_by])) {
+ if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || $requisition->tenant_id !== $award->tenant_id || $requisition->id !== $award->requisition_id
+ || in_array($user->id, [$requisition->requester_id, $award->created_by]) || app(OrganizationAccess::class)->maintenanceOnly($user) || $this->pendingCancellation($award)) {
return false;
}
$steps = $award->approvals()->get();
@@ -75,6 +77,17 @@ class PurchaseWorkflow
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
}
+ /** Eligibility to confer issuance; currentBasis and all final checks still run on issue. */
+ public function canIssue(User $user, PurchaseAward $award, Requisition $requisition): bool
+ {
+ return $user->tenant_id === $award->tenant_id && $requisition->tenant_id === $award->tenant_id && $requisition->id === $award->requisition_id
+ && $award->status === 'approved' && $user->hasFunctions([PurchasingRole::Procurement])
+ && ! app(OrganizationAccess::class)->maintenanceOnly($user) && ! $this->pendingCancellation($award)
+ && ! PurchaseOrder::query()->where('requisition_id', $requisition->id)->exists()
+ && Requisition::query()->visibleTo($user)->whereKey($requisition->id)->exists()
+ && ! $this->guard->participationBlocked($user, $requisition);
+ }
+
public function decide(User $user, PurchaseAward $award, string $decision, ?string $comment): void
{
DB::transaction(function () use ($user, $award, $decision, $comment) {
@@ -103,6 +116,7 @@ class PurchaseWorkflow
return DB::transaction(function () use ($user, $award) {
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
+ abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
@@ -313,8 +327,13 @@ class PurchaseWorkflow
private function assertNoPendingCancellation(PurchaseAward $award): void
{
- if (AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists()) {
+ if ($this->pendingCancellation($award)) {
throw ValidationException::withMessages(['cancellation' => 'Existe pedido de cancelamento pendente. Aguarde a decisão antes de aprovar ou emitir.']);
}
}
+
+ private function pendingCancellation(PurchaseAward $award): bool
+ {
+ return AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
+ }
}
diff --git a/app/Services/QuotationParticipation.php b/app/Services/QuotationParticipation.php
new file mode 100644
index 0000000..8f639dd
--- /dev/null
+++ b/app/Services/QuotationParticipation.php
@@ -0,0 +1,93 @@
+tenant_id === $request->tenant_id && $request->status === 'approved' && $actor->hasFunctions($roles)
+ && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
+ && (! $evaluation || $actor->id !== $request->requester_id)
+ && Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists(), 403);
+ }
+
+ private function assertOpen(Requisition $request, bool $evaluation): void
+ {
+ if (PurchaseAward::query()->where('requisition_id', $request->id)->exists()) {
+ throw ValidationException::withMessages(['award' => $evaluation
+ ? 'As avaliações estão fechadas pela adjudicação deste processo.'
+ : 'As propostas estão fechadas pela adjudicação deste processo.']);
+ }
+ }
+
+ public function assertPrepare(User $actor, Requisition $request): void
+ {
+ $this->assertActor($actor, $request, false);
+ $this->assertOpen($request, false);
+ app(ConflictGuard::class)->assertParticipation($actor, $request);
+ }
+
+ public function assertEvaluateProcess(User $actor, Requisition $request): void
+ {
+ $this->assertActor($actor, $request, true);
+ $this->assertOpen($request, true);
+ app(ConflictGuard::class)->assertParticipation($actor, $request);
+ }
+
+ public function assertEvaluate(User $actor, Requisition $request, Quotation $quote): void
+ {
+ $this->assertEvaluateProcess($actor, $request);
+ abort_unless($quote->tenant_id === $request->tenant_id && $quote->requisition_id === $request->id, 403);
+ app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor);
+ if ($quote->valid_until < today() || ! $quote->supplier?->eligible()) {
+ throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
+ }
+ if ($quote->evaluations()->where('evaluator_id', $actor->id)->exists()) {
+ throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
+ }
+ }
+
+ /** @param callable():void $check */
+ private function eligible(callable $check): bool
+ {
+ try {
+ $check();
+
+ return true;
+ } catch (ValidationException) {
+ return false;
+ } catch (HttpException $error) {
+ if ($error->getStatusCode() !== 403) {
+ throw $error;
+ }
+
+ return false;
+ }
+ }
+
+ public function canPrepare(User $actor, Requisition $request): bool
+ {
+ return $this->eligible(fn () => $this->assertPrepare($actor, $request));
+ }
+
+ public function canEvaluateProcess(User $actor, Requisition $request): bool
+ {
+ return $this->eligible(fn () => $this->assertEvaluateProcess($actor, $request));
+ }
+
+ public function canEvaluate(User $actor, Requisition $request, Quotation $quote): bool
+ {
+ return $this->eligible(fn () => $this->assertEvaluate($actor, $request, $quote));
+ }
+}
diff --git a/app/Services/ReceiptStockMovement.php b/app/Services/ReceiptStockMovement.php
new file mode 100644
index 0000000..d3c46b5
--- /dev/null
+++ b/app/Services/ReceiptStockMovement.php
@@ -0,0 +1,28 @@
+getDriverName()){
+ 'pgsql'=>'public','sqlite'=>'main','mysql','mariadb'=>DB::connection()->getDatabaseName(),
+ default=>throw new \LogicException('Driver de inventário não suportado.'),
+ };
+ return new \Tempo\Inventory\ReceiptMovementWriter(DB::connection(),$schema,(int)$actor->tenant_id,(int)$actor->id,
+ function () use ($actor) {app(CurrentActor::class)->lock($actor,[PurchasingRole::Warehouse]);});
+ }
+}
diff --git a/app/Services/ReceiptWorkflow.php b/app/Services/ReceiptWorkflow.php
index 53cf7fe..b210603 100644
--- a/app/Services/ReceiptWorkflow.php
+++ b/app/Services/ReceiptWorkflow.php
@@ -216,12 +216,10 @@ class ReceiptWorkflow
foreach ($lines as $line) {
$item = $receipt->items()->create($line);
if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) {
- $movement = StockMovement::query()->create([
- 'tenant_id' => $user->tenant_id, 'goods_receipt_item_id' => $item->id, 'stock_item_id' => $line['stock_item_id'],
- 'warehouse_id' => $warehouse?->id, 'moved_by' => $user->id, 'quantity_milli' => $line['accepted_milli'], 'moved_at' => now(),
- 'equipment_id' => $line['snapshot']['equipment_id'], 'spare_part_id' => $line['snapshot']['spare_part_id'],
- 'snapshot' => [...$line['snapshot'], 'receipt_id' => $receipt->id, 'order_number' => $order->number, 'delivery_note' => $deliveryNote],
- ]);
+ $writer = app(ReceiptStockMovement::class)->for($user);
+ $basis = $writer->preview($item->id);
+ $result = $writer->write($item->id, $basis['source_hash']);
+ $movement = StockMovement::query()->findOrFail($result['movement_id']);
$sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']);
app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']);
app(AutomaticPurchases::class)->signal($user, $line['stock_item_id'], $warehouse->id);
diff --git a/app/Services/ReplenishmentQueue.php b/app/Services/ReplenishmentQueue.php
new file mode 100644
index 0000000..77b4e91
--- /dev/null
+++ b/app/Services/ReplenishmentQueue.php
@@ -0,0 +1,189 @@
+
+ */
+ public function status(User $actor): array
+ {
+ abort_unless(auth()->id() === $actor->id, 403);
+ $scheduler = null;
+ $recent = false;
+ $path = storage_path('framework/replenishment-status.json');
+ if (app()->environment('payable-testing') && is_file($path)) {
+ $text = file_get_contents($path);
+ $data = $text === false ? null : json_decode($text, true);
+ if (is_array($data) && is_string($data['finished_at'] ?? null) && in_array($data['result'] ?? null, ['passed', 'retry_pending'], true)) {
+ try {
+ $finished = CarbonImmutable::parse($data['finished_at']);
+ $recent = $finished->greaterThanOrEqualTo(now()->subMinutes(3)) && $finished->lessThanOrEqualTo(now()->addMinute());
+ $scheduler = array_intersect_key($data, array_flip(['started_at', 'finished_at', 'pid', 'scope', 'result', 'exit_code']));
+ } catch (InvalidFormatException) {
+ } // Optional telemetry must not prevent operational consultation.
+ }
+ }
+
+ return ['recoveryReady' => $this->ready(), 'scanMinutes' => config('replenishment.scan_minutes'), 'schedulerStatus' => $scheduler, 'schedulerRecent' => $recent,
+ 'work' => $this->ready() ? DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->selectRaw('COUNT(*) as total, SUM(CASE WHEN generation > processed_generation THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN failures > 0 THEN 1 ELSE 0 END) as failed, MAX(last_run_at) as last_run_at')->first() : null];
+ }
+
+ public function ready(): bool
+ {
+ return config('work_context.composition') === 'sgc' && Schema::hasTable('replenishment_work');
+ }
+
+ public function enqueue(User $actor, int $item, int $warehouse): int
+ {
+ return DB::transaction(function () use ($actor, $item, $warehouse) {
+ app(ConflictGuard::class)->lockCompany($actor);
+ $actor = app(CurrentActor::class)->lock($actor);
+ StockItem::query()->whereKey($item)->firstOrFail();
+ Warehouse::query()->whereKey($warehouse)->firstOrFail();
+
+ return $this->touch($actor->tenant_id, $item, $warehouse, $actor->id, true);
+ });
+ }
+
+ private function touch(int $tenant, int $item, int $warehouse, int $responsible, bool $signal): int
+ {
+ $row = DB::table('replenishment_work')->where('tenant_id', $tenant)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
+ if ($row === null) {
+ return DB::table('replenishment_work')->insertGetId(['tenant_id' => $tenant, 'stock_item_id' => $item, 'warehouse_id' => $warehouse, 'responsible_id' => $responsible, 'generation' => 1, 'processed_generation' => 0, 'due_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
+ }
+ if ($signal) {
+ DB::table('replenishment_work')->where('id', $row->id)->update(['generation' => $row->generation + 1, 'responsible_id' => $responsible, 'due_at' => now(), 'updated_at' => now()]);
+ }
+
+ return $row->id;
+ }
+
+ /** CLI discovery recovers pre-installation interruptions and adds approved policies without stock events. */
+ public function discover(): void
+ {
+ abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
+ if (! $this->ready()) {
+ return;
+ }
+ $latest = DB::table('supply_records')->selectRaw('MAX(id)')->whereIn('kind', ['policy', 'auto_rule', 'auto_need'])->groupBy('tenant_id', 'kind', 'record_key');
+ DB::table('supply_records')->whereIn('id', $latest)->whereNotNull('stock_item_id')->whereNotNull('warehouse_id')->orderBy('id')->chunkById(100, function ($rows) {
+ foreach ($rows as $row) {
+ $data = json_decode($row->data, true, 64, JSON_THROW_ON_ERROR);
+ if ($row->kind === 'policy' && ($row->state !== 'approved' || ($data['purpose'] ?? null) !== 'internal')) {
+ continue;
+ }
+ if ($row->kind === 'auto_rule' && $row->state !== 'approved') {
+ continue;
+ }
+ $this->discoverPair($row->tenant_id, $row->stock_item_id, $row->warehouse_id, $row->created_by);
+ }
+ });
+ DB::table('warehouse_requests')->where('work_context_key', 'procurement.internal')->orderBy('id')->chunkById(100, function ($rows) {
+ foreach ($rows as $row) {
+ foreach (json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)['lines'] as $line) {
+ $this->discoverPair($row->tenant_id, $line['item']['id'], $row->warehouse_id, $row->created_by);
+ }
+ }
+ });
+ }
+
+ private function enabled(int $tenant): bool
+ {
+ $company = Tenant::query()->findOrFail($tenant);
+
+ return $company->work_context_policy === null || (in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$tenant, []), true));
+ }
+
+ private function discoverPair(int $tenant, int $item, int $warehouse, int $responsible): void
+ {
+ DB::transaction(function () use ($tenant, $item, $warehouse, $responsible) {
+ Tenant::query()->whereKey($tenant)->lockForUpdate()->firstOrFail();
+ if (! $this->enabled($tenant)) {
+ return;
+ }
+ if (! DB::table('users')->where('tenant_id', $tenant)->where('id', $responsible)->exists() || ! DB::table('stock_items')->where('tenant_id', $tenant)->where('id', $item)->exists() || ! DB::table('warehouses')->where('tenant_id', $tenant)->where('id', $warehouse)->exists()) {
+ return;
+ }
+ $this->touch($tenant, $item, $warehouse, $responsible, false);
+ });
+ }
+
+ public function interactive(User $actor, int $id): bool
+ {
+ return $this->process($id, $actor);
+ }
+
+ public function scheduled(int $id): bool
+ {
+ return app(ReplenishmentRun::class)->execute($id, fn () => $this->process($id, null));
+ }
+
+ private function process(int $id, ?User $actor): bool
+ {
+ $work = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
+ if ($actor !== null) {
+ abort_unless($actor->tenant_id === $work->tenant_id && auth()->id() === $actor->id, 403);
+ }
+ $generation = null;
+ try {
+ DB::transaction(function () use ($id, $actor, &$generation) {
+ $first = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
+ Tenant::query()->whereKey($first->tenant_id)->lockForUpdate()->firstOrFail();
+ $row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
+ if (CarbonImmutable::parse($row->due_at)->isFuture() && $row->generation === $row->processed_generation) {
+ return;
+ }
+ $generation = $row->generation;
+ if ($actor === null) {
+ $actor = User::query()->where('tenant_id', $row->tenant_id)->whereKey($row->responsible_id)->firstOrFail();
+ }
+ if ($this->enabled($row->tenant_id)) {
+ app(AutomaticPurchases::class)->evaluate($actor, $row->stock_item_id, $row->warehouse_id);
+ }
+ $latest = app(SupplyPlanning::class)->latest('auto_need')->where('stock_item_id', $row->stock_item_id)->where('warehouse_id', $row->warehouse_id)->first();
+ DB::table('replenishment_work')->where('id', $id)->update(['processed_generation' => $generation, 'attempts' => $row->attempts + 1, 'failures' => 0, 'last_outcome' => $this->enabled($row->tenant_id) ? ($latest->state ?? 'no_need') : 'context_disabled', 'last_error' => null, 'last_run_at' => now(), 'due_at' => now()->addMinutes(config('replenishment.scan_minutes')), 'updated_at' => now()]);
+ });
+
+ return true;
+ } catch (Throwable $error) {
+ report($error);
+ // The failed transaction rolled back both draft and checkpoint. Keep the durable signal.
+ DB::transaction(function () use ($id, $work, $generation) {
+ Tenant::query()->whereKey($work->tenant_id)->lockForUpdate()->firstOrFail();
+ $row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
+ $fields = ['attempts' => $row->attempts + 1, 'failures' => $row->failures + 1, 'last_outcome' => 'error', 'last_error' => 'Avaliação interrompida. Recuperação automática pendente; consulte o registo técnico.', 'updated_at' => now()];
+ if ($generation !== null && $generation === $row->generation) {
+ $fields['due_at'] = now()->addSeconds(min(3600, 60 * (2 ** min($row->failures, 6))));
+ }
+ DB::table('replenishment_work')->where('id', $id)->update($fields);
+ });
+
+ return false;
+ }
+ }
+
+ /** @return array{processed:int,failed:int} */
+ public function drain(int $limit): array
+ {
+ abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
+ $result = ['processed' => 0, 'failed' => 0];
+ foreach (DB::table('replenishment_work')->where('due_at', '<=', now())->orderBy('due_at')->orderBy('id')->limit($limit)->pluck('id') as $id) {
+ $result[$this->scheduled($id) ? 'processed' : 'failed']++;
+ }
+
+ return $result;
+ }
+}
diff --git a/app/Services/ReplenishmentRun.php b/app/Services/ReplenishmentRun.php
new file mode 100644
index 0000000..aa9c89e
--- /dev/null
+++ b/app/Services/ReplenishmentRun.php
@@ -0,0 +1,54 @@
+tenant;
+ }
+
+ public function lockResponsible(User $user): User
+ {
+ abort_unless($this->tenant !== null && $user->tenant_id === $this->tenant && DB::transactionLevel() > 0 && ! auth()->check(), 403);
+ // A stock writer may have updated the signal while this worker waited for the company lock.
+ $responsible = DB::table('replenishment_work')->where('tenant_id', $this->tenant)->where('id', $this->work)->value('responsible_id');
+ abort_unless($user->id === $responsible, 403);
+ $this->responsible = $responsible;
+
+ return User::query()->where('tenant_id', $this->tenant)->whereKey($user->id)->lockForUpdate()->firstOrFail();
+ }
+
+ /** @return array */
+ public function evidence(): array
+ {
+ return ['mode' => 'scheduled_replenishment', 'work_id' => $this->work, 'tenant_id' => $this->tenant, 'responsible_user_id' => $this->responsible, 'attribution' => 'responsible_for_trigger_or_rule; not_an_interactive_action'];
+ }
+
+ public function execute(int $id, callable $callback): mixed
+ {
+ abort_unless(config('work_context.composition') === 'sgc' && app()->runningInConsole() && ! auth()->check() && $this->tenant === null && (! request()->hasSession()), 403);
+ $work = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
+ $this->tenant = $work->tenant_id;
+ $this->work = $work->id;
+ $this->responsible = $work->responsible_id;
+ try {
+ return $callback();
+ } finally {
+ $this->tenant = null;
+ $this->work = null;
+ $this->responsible = null;
+ }
+ }
+}
diff --git a/app/Services/RequisitionCatalogue.php b/app/Services/RequisitionCatalogue.php
index ab7c3a8..66f0ad1 100644
--- a/app/Services/RequisitionCatalogue.php
+++ b/app/Services/RequisitionCatalogue.php
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\RequisitionItem;
+use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\User;
use Illuminate\Support\Facades\DB;
@@ -37,8 +38,11 @@ class RequisitionCatalogue
$actor = app(CurrentActor::class)->lock($actor);
abort_unless($this->canManage($actor), 403);
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'stock_basis' => 'required|string|size:64', 'category' => 'required|string|max:100',
- 'purchase_type' => 'required|in:consumable,other', 'published' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'])->validate();
+ 'purchase_type' => 'required|in:consumable,other,spare_part', 'published' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'])->validate();
$stock = StockItem::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($stockId);
+ if ($data['purchase_type'] === 'spare_part' && ! SparePart::query()->where('stock_item_id', $stock->id)->exists()) {
+ throw ValidationException::withMessages(['purchase_type' => 'Registe a peça e os equipamentos compatíveis antes de a publicar como peça.']);
+ }
$entry = DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('stock_item_id', $stock->id)->lockForUpdate()->first();
if ((int) $data['version'] !== (int) ($entry->version ?? 0) || $data['stock_basis'] !== $this->basis($stock)) {
throw ValidationException::withMessages(['version' => 'O artigo ou a publicação mudou. Actualize e confira antes de guardar.']);
@@ -119,6 +123,10 @@ class RequisitionCatalogue
$snapshot = ['entry_id' => $entry->id, 'version' => $entry->version, ...$this->sourceDescriptor($entry)];
}
+ if ($snapshot['purchase_type'] === 'spare_part' && ! SparePart::query()->whereKey($line['spare_part_id'] ?? 0)->where('stock_item_id', $snapshot['stock_item_id'])->exists()) {
+ throw ValidationException::withMessages(['items' => 'A peça seleccionada não corresponde ao artigo publicado no catálogo.']);
+ }
+
return [...$line, 'catalogue_entry_id' => $snapshot['entry_id'], 'catalogue_snapshot' => $snapshot,
'description' => $snapshot['description'], 'unit' => $snapshot['unit'], 'category' => $snapshot['category'], 'purchase_type' => $snapshot['purchase_type']];
}
diff --git a/app/Services/RequisitionWorkflow.php b/app/Services/RequisitionWorkflow.php
index 0da6869..e540a82 100644
--- a/app/Services/RequisitionWorkflow.php
+++ b/app/Services/RequisitionWorkflow.php
@@ -29,7 +29,12 @@ class RequisitionWorkflow
unset($data['operation_key'], $data['process_version']);
$result = $this->execute($user, 'save', $existing?->id, $version, $key, $data);
- return Requisition::query()->whereKey($result['id'])->firstOrFail();
+ $record = Requisition::query()->whereKey($result['id'])->firstOrFail();
+ if ($existing !== null) {
+ app(AutomaticPurchases::class)->requisitionChanged($user, $record);
+ }
+
+ return $record;
}
/**
@@ -51,7 +56,7 @@ class RequisitionWorkflow
{
return DB::transaction(function () use ($user, $data, $existing, $automaticRule) {
app(ConflictGuard::class)->lockCompany($user);
- $user = app(CurrentActor::class)->lock($user);
+ $user = $automaticRule === null ? app(CurrentActor::class)->lock($user) : app(AutomaticPurchases::class)->lockExecutor($user);
$executor = $user;
if ($automaticRule !== null) {
abort_unless($existing === null, 403);
@@ -116,23 +121,27 @@ class RequisitionWorkflow
});
}
- /** Only an independently activated rule may create a draft on behalf of its designated requester. */
+ /** Only an independently activated rule may create a draft on behalf of its designated requester.
+ * @param array $data
+ */
public function automaticDraft(User $executor, SupplyRecord $rule, array $data): Requisition
{
$process = new InternalRequisitions(new InternalRequisitionPort($executor, fn (array $input, ?Requisition $existing) => $this->persistDraft($executor, $input, $existing, $rule), $rule));
$result = $process->command('save', null, null, null, $data);
- return Requisition::query()->findOrFail($result['id']);
+ return Requisition::query()->whereKey($result['id'])->firstOrFail();
}
public function submit(User $user, Requisition $requisition, ?string $operationKey = null, ?int $version = null): void
{
$this->execute($user, 'submit', $requisition->id, $version, $operationKey);
+ app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
}
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment, ?string $operationKey = null, ?int $version = null): void
{
$this->execute($user, 'decide', $requisition->id, $version, $operationKey, ['decision' => $decision, 'comment' => $comment]);
+ app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
}
/** @param array|null $before
@@ -143,7 +152,7 @@ class RequisitionWorkflow
AuditLog::query()->create([
'tenant_id' => $user->tenant_id, 'user_id' => $user->id,
'requisition_id' => $requisition?->id, 'action' => $action,
- 'ip_address' => request()->ip(), 'before' => $before, 'after' => $after,
+ 'ip_address' => app(ReplenishmentRun::class)->tenantId() === null ? request()->ip() : null, 'before' => $before, 'after' => $after + (app(ReplenishmentRun::class)->tenantId() === null ? [] : ['execution' => app(ReplenishmentRun::class)->evidence()]),
]);
}
}
diff --git a/app/Services/StockIssueMovement.php b/app/Services/StockIssueMovement.php
new file mode 100644
index 0000000..a307d0e
--- /dev/null
+++ b/app/Services/StockIssueMovement.php
@@ -0,0 +1,20 @@
+getDriverName()){'pgsql'=>'public','sqlite'=>'main','mysql','mariadb'=>DB::connection()->getDatabaseName(),default=>throw new \LogicException('Driver não suportado.')};
+ return new \Tempo\Inventory\IssueMovementWriter(DB::connection(),$schema,(int)$actor->tenant_id,(int)$actor->id,function()use($actor){app(CurrentActor::class)->lock($actor,[PurchasingRole::Warehouse]);});
+ }
+}
diff --git a/app/Services/StockWorkflow.php b/app/Services/StockWorkflow.php
index 0c74ba1..2b0fd08 100644
--- a/app/Services/StockWorkflow.php
+++ b/app/Services/StockWorkflow.php
@@ -278,6 +278,10 @@ class StockWorkflow
private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): StockMovement
{
+ if($type==='issue'&&$operation->kind==='issue'){
+ $writer=app(StockIssueMovement::class)->for($actor);$result=$writer->write($operation->id,$writer->preview($operation->id));
+ return StockMovement::query()->findOrFail($result['movement_id']);
+ }
return StockMovement::query()->create(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'stock_item_id' => $operation->stock_item_id, 'warehouse_id' => $warehouse, 'equipment_id' => $operation->equipment_id, 'spare_part_id' => $operation->spare_part_id, 'moved_by' => $actor->id, 'movement_type' => $type, 'quantity_milli' => $quantity, 'snapshot' => [...$operation->snapshot, 'warehouse' => Warehouse::query()->findOrFail($warehouse)->only(['id', 'code', 'name']), 'reference' => $operation->reference, 'reason' => $operation->reason], 'moved_at' => now()]);
}
}
diff --git a/app/Services/SupplyPlanning.php b/app/Services/SupplyPlanning.php
index c7ee695..d8085f3 100644
--- a/app/Services/SupplyPlanning.php
+++ b/app/Services/SupplyPlanning.php
@@ -25,6 +25,24 @@ class SupplyPlanning
return $actor->hasFunctions($functions) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
}
+ public function independentReviewer(User $actor, SupplyRecord $record): bool
+ {
+ return $actor->tenant_id === $record->tenant_id && $this->allows($actor, 'supply_review')
+ && ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true);
+ }
+
+ /** Eligibility to confer, not a promise that changed sources permit approval. */
+ public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool
+ {
+ if (! in_array($record->kind, ['agreement', 'policy'], true) || ! in_array($action, ['approve', 'return', 'suspend'], true)
+ || ! $this->independentReviewer($actor, $record)) {
+ return false;
+ }
+ $latest = $this->latest($record->kind)->where('record_key', $record->record_key)->first();
+
+ return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true));
+ }
+
/** @return Builder */
public function latest(string $kind)
{
@@ -115,7 +133,7 @@ class SupplyPlanning
$data = $old->data;
if ($kind !== 'import') {
$this->check(in_array($action, ['approve', 'return', 'suspend'], true), 'action', 'Acção incompatível com este registo.');
- $this->check(! in_array($actor->id, $old->snapshot['contributors'] ?? [$old->created_by], true), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.');
+ $this->check($this->independentReviewer($actor, $old), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.');
$this->check($action === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
$state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$action];
} else {
diff --git a/app/Services/WarehouseExchange.php b/app/Services/WarehouseExchange.php
new file mode 100644
index 0000000..9348ca3
--- /dev/null
+++ b/app/Services/WarehouseExchange.php
@@ -0,0 +1,101 @@
+ */
+ public function page(User $actor, ?string $cursor = null, int $limit = 50): array
+ {
+ abort_unless(auth('web')->id() === $actor->id && $actor->tenant_id !== null
+ && $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
+ $contexts = app(WorkContext::class);
+ $enforced = $contexts->enforced($actor);
+ abort_unless(! $enforced || $contexts->active(request()) === 'procurement.internal', 403);
+ abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Instalação das entregas pendente.');
+ abort_unless($limit >= 1 && $limit <= 100, 422);
+ $permissions = app(OrganizationAccess::class)->permissions($actor);
+ sort($permissions);
+ $permissionFingerprint = hash('sha256', json_encode($permissions, JSON_THROW_ON_ERROR));
+ $scope = [self::SCHEMA, $actor->tenant_id, $actor->id, $enforced ? 'procurement.internal' : 'legacy', $actor->department_id, $permissionFingerprint];
+ $after = 0;
+ if ($cursor !== null) {
+ try {
+ $decoded = json_decode(Crypt::decryptString($cursor), true, 8, JSON_THROW_ON_ERROR);
+ } catch (DecryptException|\JsonException $e) {
+ throw ValidationException::withMessages(['cursor' => 'Cursor inválido. Reinicie a leitura.']);
+ }
+ if (! is_array($decoded) || ($decoded['scope'] ?? null) !== $scope
+ || ! is_int($decoded['after'] ?? null) || $decoded['after'] < 0) {
+ throw ValidationException::withMessages(['cursor' => 'O cursor pertence a outro âmbito. Reinicie a leitura.']);
+ }
+ $after = $decoded['after'];
+ }
+
+ return DB::transaction(function () use ($actor, $enforced, $scope, $after, $limit) {
+ if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ }
+ $requests = app(WarehouseWorkdesk::class)->visible($actor)->where('tenant_id', $actor->tenant_id);
+ $requests->where(fn ($q) => $q->where('work_context_key', 'procurement.internal')
+ ->when(! $enforced, fn ($q) => $q->orWhereNull('work_context_key')));
+ $events = WarehouseRequestEvent::query()->where('tenant_id', $actor->tenant_id)
+ ->whereIn('warehouse_request_id', (clone $requests)->select('id'))
+ ->whereIn('action', ['deliver', 'receive'])->where('id', '>', $after)->orderBy('id')->limit($limit + 1)->get();
+ $more = $events->count() > $limit;
+ $events = $events->take($limit);
+ $sources = $requests->whereIn('id', $events->pluck('warehouse_request_id'))->get()->keyBy('id');
+ $items = [];
+ foreach ($events as $event) {
+ $source = $sources->get($event->getAttribute('warehouse_request_id'));
+ abort_unless($source instanceof WarehouseRequest, 409, 'Origem da entrega indisponível.');
+ $items[] = $this->envelope($source, $event);
+ }
+
+ return ['schema' => self::SCHEMA, 'items' => $items, 'has_more' => $more,
+ 'next_cursor' => Crypt::encryptString(json_encode(['scope' => $scope, 'after' => $events->max('id') ?? $after], JSON_THROW_ON_ERROR))];
+ });
+ }
+
+ /** @return array */
+ private function envelope(WarehouseRequest $request, WarehouseRequestEvent $event): array
+ {
+ $key = 'sgc:'.$request->tenant_id.':warehouse-event:'.$event->id;
+ $snapshot = $request->snapshot;
+ $lines = [];
+ if ($event->getAttribute('action') === 'deliver') {
+ foreach ($event->data['lines'] as $line) {
+ $source = $snapshot['lines'][$line['index']];
+ $lines[] = ['request_line_index' => $line['index'], 'item' => $source['item'],
+ 'quantity_milli' => $line['quantity_milli'], 'use' => $source['use'],
+ 'destination' => $source['destination'], 'equipment' => $source['equipment'],
+ 'stock_operation_id' => $line['stock_operation_id']];
+ }
+ }
+ $payload = ['schema' => 'sgc.warehouse.fulfilment-event.v1', 'idempotency_key' => $key,
+ 'tenant_id' => $request->tenant_id, 'event_id' => $event->id,
+ 'event_version' => $event->getAttribute('version'), 'action' => $event->getAttribute('action'),
+ 'occurred_at' => CarbonImmutable::parse($event->getAttribute('created_at'))->utc()->toIso8601String(), 'actor' => $event->data['actor'],
+ 'request' => ['id' => $request->id, 'reference' => 'RA-'.$request->id, 'document_type' => 'internal_warehouse_request',
+ 'work_context_key' => $request->getAttribute('work_context_key'),
+ 'requester' => $snapshot['requester'], 'warehouse' => $snapshot['warehouse'],
+ 'cost_center' => $snapshot['cost_center'], 'destination' => $snapshot['destination']],
+ 'lines' => $lines, 'delivery_event_key' => $event->getAttribute('action') === 'receive'
+ ? 'sgc:'.$request->tenant_id.':warehouse-event:'.$event->data['delivery_event_id'] : null];
+
+ return [...$payload, 'payload_sha256' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))];
+ }
+}
diff --git a/app/Services/WarehouseFulfilment.php b/app/Services/WarehouseFulfilment.php
index a1bbb5d..0a5564e 100644
--- a/app/Services/WarehouseFulfilment.php
+++ b/app/Services/WarehouseFulfilment.php
@@ -175,7 +175,7 @@ class WarehouseFulfilment
$event = WarehouseRequestEvent::query()->create(['tenant_id' => $actor->tenant_id, 'warehouse_request_id' => $id, 'actor_id' => $actor->id, 'version' => $p['version'] + 1,
'operation_key' => $data['operation_key'], 'input_hash' => $hash, 'action' => $action, 'reason' => $data['reason'], 'data' => $eventData, 'created_at' => now()]);
if ($action === 'authorize') {
- app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $eventData['partial_allowed'], $data['operation_key']);
+ app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $data['partial_allowed'] ?? false, $data['operation_key']);
}
if ($action === 'reserve') {
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'authorize')->firstOrFail();
diff --git a/app/Services/WarehousePilotReadiness.php b/app/Services/WarehousePilotReadiness.php
new file mode 100644
index 0000000..0892373
--- /dev/null
+++ b/app/Services/WarehousePilotReadiness.php
@@ -0,0 +1,115 @@
+ */
+ public function inspect(User $actor): array
+ {
+ abort_unless(auth()->id() === $actor->id && $actor->hasFunctions(['admin']), 403);
+ $users = User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'tenant_id', 'name', 'department_id', 'purchasing_role']);
+ $centers = CostCenter::query()->get(['id', 'department_id']);
+ $accessReady = Schema::hasTable('organization_memberships') && Schema::hasTable('organization_profiles');
+ $groups = ['prepare' => [], 'review' => [], 'deliver' => [], 'purchase_requester' => [], 'supply_prepare' => [], 'supply_review' => []];
+ $permanentAdmins = 0;
+ foreach ($users as $user) {
+ $grants = $accessReady ? DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $actor->tenant_id)->where('p.tenant_id', $actor->tenant_id)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions') : collect();
+ if ($grants->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
+ $permanentAdmins++;
+ }
+ if (app(OrganizationAccess::class)->maintenanceOnly($user)) {
+ continue;
+ }
+ $context = app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null;
+ if ($context !== null && ! in_array($context, app(WorkContext::class)->available($user), true)) {
+ continue;
+ }
+ $permissions = app(OrganizationAccess::class)->permissions($user, $context);
+ $person = $user->only(['id', 'name', 'department_id']) + ['has_cost_center' => $user->department_id !== null && $centers->contains('department_id', $user->department_id)];
+ foreach (['prepare' => 'warehouse_request_prepare', 'review' => 'warehouse_request_review', 'purchase_requester' => 'requester', 'supply_prepare' => 'supply_prepare', 'supply_review' => 'supply_review'] as $key => $permission) {
+ if (in_array($permission, $permissions, true)) {
+ $groups[$key][] = $person;
+ }
+ }
+ if (in_array('warehouse_request_deliver', $permissions, true) && in_array('warehouse', $permissions, true)) {
+ $groups['deliver'][] = $person;
+ }
+ }
+ $independent = false;
+ $deliveryIds = array_fill_keys(array_column($groups['deliver'], 'id'), true);
+ foreach ($groups['prepare'] as $requester) {
+ foreach ($groups['review'] as $reviewer) {
+ if ($requester['id'] === $reviewer['id'] || $requester['department_id'] === null || $requester['department_id'] !== $reviewer['department_id'] || ! $requester['has_cost_center']) {
+ continue;
+ }
+ if (count($deliveryIds) > (isset($deliveryIds[$requester['id']]) ? 1 : 0) + (isset($deliveryIds[$reviewer['id']]) ? 1 : 0)) {
+ $independent = true;
+ break 2;
+ }
+ }
+ }
+ $tables = [];
+ foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue'] as $table) {
+ $tables[$table] = Schema::hasTable($table);
+ }
+ $warehouses = Warehouse::query()->count();
+ $items = StockItem::query()->get(['id', 'code', 'name', 'unit'])->keyBy('id');
+ $published = 0;
+ $stale = 0;
+ if ($tables['requisition_catalogue']) {
+ foreach (DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->get(['stock_item_id', 'source_snapshot']) as $entry) {
+ $item = $items->get($entry->stock_item_id);
+ if ($item !== null && app(RequisitionCatalogue::class)->source($item) === json_decode($entry->source_snapshot, true, 32, JSON_THROW_ON_ERROR)) {
+ $published++;
+ } else {
+ $stale++;
+ }
+ }
+ }
+ $planning = app(SupplyPlanning::class);
+ $policies = $tables['supply_records'] ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->count() : 0;
+ $validRules = 0;
+ $invalidRules = 0;
+ if (! in_array(false, $tables, true)) {
+ foreach ($planning->latest('auto_rule')->where('state', 'approved')->cursor() as $rule) {
+ $status = app(AutomaticPurchases::class)->inspectRule($actor, $rule);
+ if ($status['valid']) {
+ $validRules++;
+ } else {
+ $invalidRules++;
+ }
+ }
+ }
+ $runtime = app(ReplenishmentQueue::class)->status($actor);
+ $pending = (int) ($runtime['work']->pending ?? 0);
+ $failed = (int) ($runtime['work']->failed ?? 0);
+ $recoveryClear = $runtime['recoveryReady'] && $pending === 0 && $failed === 0;
+ $active = app(OrganizationAccess::class)->active($actor->tenant_id);
+ $company = Tenant::query()->findOrFail($actor->tenant_id);
+ $internalConfigured = $company->work_context_policy !== null && in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$actor->tenant_id, []), true);
+ $checks = [
+ ['key' => 'installation', 'label' => 'Instalação dos percursos', 'ok' => ! in_array(false, $tables, true), 'detail' => in_array(false, $tables, true) ? 'Existem componentes por instalar.' : 'Requisições, reservas, catálogo e recuperação durável instalados.', 'path' => null],
+ ['key' => 'participants', 'label' => 'Tarefas e independência no armazém', 'ok' => $independent, 'detail' => $independent ? 'Há combinação elegível de requisitante, autorizador e entregador distintos, com centro de custo do departamento.' : 'Faltam tarefas, centro de custo ou uma combinação de três intervenientes independentes. Não são atribuídas competências nesta consulta.', 'path' => '/administracao/parametrizacao'],
+ ['key' => 'catalogue', 'label' => 'Artigos e armazéns', 'ok' => $warehouses > 0 && $published > 0, 'detail' => $warehouses.' armazém(ns), '.$published.' artigo(s) publicado(s) com origem actual e '.$stale.' publicação(ões) a rever.', 'path' => '/stock/catalogo'],
+ ['key' => 'replenishment', 'label' => 'Reposição parametrizada e válida', 'ok' => $validRules > 0, 'detail' => $policies.' política(s) interna(s) aprovada(s), '.$validRules.' regra(s) activa(s) válida(s) e '.$invalidRules.' a rever. Falta de stock não é, por si, erro de configuração.', 'path' => '/stock/reposicao'],
+ ['key' => 'scheduler', 'label' => 'Verificação periódica observada', 'ok' => $runtime['recoveryReady'] && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed', 'detail' => $runtime['schedulerRecent'] ? 'Há uma passagem recente conhecida do serviço.' : 'Sem passagem recente confirmada nesta interface; instalação não prova execução.', 'path' => '/stock/reposicao'],
+ ['key' => 'administration', 'label' => 'Configuração própria da empresa', 'ok' => $active && $permanentAdmins >= 2 && $internalConfigured, 'detail' => $active ? 'Perfis próprios activos; '.$permanentAdmins.' administrador(es) permanente(s). Conferir também o contexto interno contratado/activado.' : 'Acesso ainda em compatibilidade com funções de desenvolvimento. Isto não é configuração de cliente concluída.', 'path' => '/administracao/parametrizacao'],
+ ['key' => 'recovery', 'label' => 'Trabalhos técnicos de reposição', 'ok' => $recoveryClear, 'detail' => $pending.' sinal(is) por processar e '.$failed.' trabalho(s) com recuperação pendente. Necessidades funcionais sem configuração válida continuam visíveis na reposição.', 'path' => '/stock/reposicao'],
+ ];
+
+ return ['checked_at' => now()->toIso8601String(), 'checks' => $checks, 'participants' => $groups, 'registered' => $users->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->all(), 'tables' => $tables, 'runtime' => $runtime,
+ 'warehouse_scenario_ready' => ! in_array(false, $tables, true) && $independent && $warehouses > 0 && $published > 0,
+ 'replenishment_scenario_ready' => ! in_array(false, $tables, true) && $recoveryClear && $validRules > 0 && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed',
+ 'client_configuration_complete' => $active && $permanentAdmins >= 2 && $internalConfigured];
+ }
+}
diff --git a/app/Services/WarehouseReservations.php b/app/Services/WarehouseReservations.php
index 263e8d1..368c8df 100644
--- a/app/Services/WarehouseReservations.php
+++ b/app/Services/WarehouseReservations.php
@@ -18,7 +18,7 @@ class WarehouseReservations
public function committed(int $item, int $warehouse): int
{
- return $this->ready() ? (int) DB::table('warehouse_reservations')->where('tenant_id', auth()->user()?->tenant_id)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->sum('quantity_milli') : 0;
+ return $this->ready() ? (int) DB::table('warehouse_reservations')->where('tenant_id', auth()->user()?->tenant_id ?? app(ReplenishmentRun::class)->tenantId())->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->sum('quantity_milli') : 0;
}
/** @return array */
@@ -114,10 +114,10 @@ class WarehouseReservations
/** Refuse worsening a shortage of usable reserved material; quality holds remain independent. */
public function protect(int $item, int $warehouse, int $before): void
{
- if (! $this->ready() || $this->committed($item,$warehouse) === 0) {
+ if (! $this->ready() || $this->committed($item, $warehouse) === 0) {
return;
}
- if ($this->free($item,$warehouse) < min(0,$before)) {
+ if ($this->free($item, $warehouse) < min(0, $before)) {
throw ValidationException::withMessages(['quantity' => 'Esta operação consumiria material reservado. Liberte ou regularize a reserva autorizadamente.']);
}
}
diff --git a/app/Services/WarehouseReturnMovement.php b/app/Services/WarehouseReturnMovement.php
new file mode 100644
index 0000000..2bbc4ba
--- /dev/null
+++ b/app/Services/WarehouseReturnMovement.php
@@ -0,0 +1,42 @@
+fileHash($root.'/manifest.json'))) {
+ abort(503, 'Pacote de devolução incompatível.');
+ }
+ $raw = file_get_contents($root.'/manifest.json');
+ if ($raw === false) {
+ abort(503, 'Manifesto de devolução indisponível.');
+ }
+ $manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
+ foreach (['bootstrap.php', 'src/DeliveryReturnWriter.php'] as $file) {
+ if (! hash_equals($manifest['files'][$file] ?? '', $this->fileHash($root.'/'.$file))) {
+ abort(503, 'Pacote de devolução alterado.');
+ }
+ }
+ require_once $root.'/bootstrap.php';
+
+ return new DeliveryReturnWriter(DB::connection(), 'public', (int) $actor->tenant_id, (int) $actor->id, function () use ($actor) {
+ app(WarehouseReturns::class)->authorize($actor, 'confirm');
+ });
+ }
+}
diff --git a/app/Services/WarehouseReturns.php b/app/Services/WarehouseReturns.php
new file mode 100644
index 0000000..90842f7
--- /dev/null
+++ b/app/Services/WarehouseReturns.php
@@ -0,0 +1,261 @@
+ unused material declaration -> independently received physical return. */
+final class WarehouseReturns
+{
+ public function allows(User $actor, string $action): bool
+ {
+ $access = app(OrganizationAccess::class);
+ $scope = app(WorkContext::class)->enforced($actor) ? $this->context() : null;
+ $permissions = $access->permissions($actor, $scope);
+
+ return ! $access->maintenanceOnly($actor) && in_array('warehouse_return_'.$action, $permissions, true) && ($action !== 'confirm' || in_array('warehouse', $permissions, true));
+ }
+
+ public function authorize(User $actor, string $action): User
+ {
+ abort_unless(config('stock_returns.enabled'), 503, 'Devoluções de excedentes ainda não activadas.');
+ abort_unless(DB::connection()->getDriverName() === 'pgsql', 503, 'Percurso de devoluções ainda não validado neste driver.');
+ abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
+ $fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
+ abort_unless($this->allows($fresh, $action), 403);
+ abort_unless(Schema::hasTable('supply_records'), 503, 'Histórico administrativo ainda não instalado.');
+
+ return $fresh;
+ }
+
+ /** @param array $v */
+ private function hash(array $v): string
+ {
+ return hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
+ }
+
+ private function lock(User $actor, string $action): User
+ {
+ $timeout=config('stock_returns.lock_timeout_ms',2000);
+ abort_unless(is_int($timeout)&&$timeout>=100&&$timeout<=10000,503,'Tempo de espera de conferência inválido.');
+ DB::select("SELECT set_config('lock_timeout',?,true)",[$timeout.'ms']);
+ app(ConflictGuard::class)->lockCompany($actor);
+ $fresh = app(CurrentActor::class)->lock($actor);
+
+ return $this->authorize($fresh, $action);
+ }
+
+ private function context(): string
+ {
+ $actor = auth()->user();
+ abort_unless($actor instanceof User, 403);
+ if (! app(WorkContext::class)->enforced($actor)) {
+ return 'procurement.internal';
+ }
+ abort_unless(request()->hasSession(), 403);
+ $key = app(WorkContext::class)->active(request());
+ abort_unless($key === 'procurement.internal', 403);
+
+ return $key;
+ }
+
+ private function request(int $id): WarehouseRequest
+ {
+ $r = WarehouseRequest::query()->findOrFail($id);
+ abort_unless(($r->work_context_key ?? 'procurement.internal') === $this->context(), 403);
+
+ return $r;
+ }
+
+ /** @return array */
+ public function availableDeliveries(User $actor, int $requestId, int $page): array
+ {
+ $this->authorize($actor, 'read');
+ $r = $this->request($requestId);
+ abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $r), 403);
+ $events = WarehouseRequestEvent::query()->where('warehouse_request_id', $r->id)->where('action', 'deliver')->whereExists(function ($q) use ($actor, $r) {
+ $q->selectRaw('1')->from('warehouse_request_events AS ack')->where('ack.tenant_id', $actor->tenant_id)->where('ack.warehouse_request_id', $r->id)->where('ack.action', 'receive')->whereRaw("(ack.data->>'delivery_event_id')::bigint=warehouse_request_events.id");
+ })->orderByDesc('id')->paginate(25, ['id', 'data'], 'source_page', $page);
+ $options = [];
+ foreach ($events as $event) {
+ foreach ($event->data['lines'] as $line) {
+ $basis = $r->snapshot['lines'][$line['index']] ?? null;
+ if ($basis && $basis['use'] === 'internal' && $basis['equipment'] === null) {
+ $options[] = ['delivery_event_id' => $event->id, 'line_index' => $line['index'], 'item' => $basis['item'], 'delivered_milli' => (string) $line['quantity_milli']];
+ }
+ }
+ }
+
+ return ['request_id' => $r->id, 'can_prepare' => $r->created_by === $actor->id && $this->allows($actor, 'prepare'), 'options' => $options, 'pagination' => ['page' => $events->currentPage(), 'last_page' => $events->lastPage(), 'total' => $events->total()]];
+ }
+
+ /**
+ * @param list> $lines
+ * @return array|null
+ */
+ private function deliveryLine(array $lines, int $index): ?array
+ {
+ foreach ($lines as $line) {
+ if ($line['index'] === $index) {
+ return $line;
+ }
+ }
+
+return null;
+ }
+
+ /**
+ * @param array $d
+ * @return array
+ */
+ private function source(User $actor, array $d): array
+ {
+ $event = WarehouseRequestEvent::query()->where('action', 'deliver')->whereKey($d['delivery_event_id'])->lockForUpdate()->firstOrFail();
+ $request = $this->request($event->warehouse_request_id);
+ $line = $this->deliveryLine($event->data['lines'], $d['line_index']);
+ $basis = $request->snapshot['lines'][$d['line_index']] ?? null;
+ abort_unless($line && $basis && $basis['use'] === 'internal' && $basis['equipment'] === null, 409, 'Apenas excedente de entrega interna simples, sem equipamento ou venda.');
+ $acknowledgement = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'receive')->where('data->delivery_event_id', $event->id)->lockForUpdate()->first();
+ abort_unless($acknowledgement !== null, 409, 'A entrega original ainda não foi recebida pelo destinatário.');
+ $operation = DB::table('stock_operations')->where(['tenant_id' => $actor->tenant_id, 'id' => $line['stock_operation_id'], 'kind' => 'issue'])->lockForUpdate()->first();
+ abort_unless($operation && (int) $operation->stock_item_id === $basis['item']['id'] && (int) $operation->warehouse_id === $request->warehouse_id && $operation->equipment_id === null && $operation->spare_part_id === null, 409, 'Entrega incompatível com o documento interno.');
+ $movement = DB::table('stock_movements')->where(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'movement_type' => 'issue'])->lockForUpdate()->first();
+ abort_unless($movement && (int) $movement->quantity_milli === -(int) $line['quantity_milli'], 409, 'Quantidade da entrega incompatível.');
+ $snapshot = json_decode($movement->snapshot, true, 512, JSON_THROW_ON_ERROR);
+ abort_unless(($snapshot['inventory_operation_hash'] ?? null) === $this->hash((array) $operation), 409, 'Entrega sem fotografia original íntegra.');
+ abort_if(SupplyRecord::query()->where('kind', 'stock_reversal')->where('state', 'executed')->where('data->source_kind', 'operation')->where('data->source_operation_id', $operation->id)->exists(), 409, 'Entrega estornada; exige reconciliação própria.');
+ abort_if(app(InventoryLots::class)->setup($operation->stock_item_id, $operation->warehouse_id) !== null || app(WarehouseReservations::class)->committed($operation->stock_item_id, $operation->warehouse_id) > 0, 409, 'Lotes ou reservas exigem percurso próprio.');
+ $item = StockItem::query()->whereKey($operation->stock_item_id)->firstOrFail();
+ $warehouse = Warehouse::query()->whereKey($operation->warehouse_id)->firstOrFail();
+ abort_unless($item->unit === $basis['item']['unit'], 409, 'Unidade do artigo mudou; exige reconciliação própria, sem conversão automática.');
+ $returned = (int) StockMovement::query()->where('movement_type', 'workshop_return_in')->where('snapshot->delivery_movement_id', $movement->id)->sum('quantity_milli');
+ $remaining = (int) $line['quantity_milli'] - $returned;
+ abort_unless($d['quantity_milli'] > 0 && $d['quantity_milli'] <= $remaining, 409, 'Quantidade excede a entrega ainda não devolvida.');
+ $book = app(StockWorkflow::class)->balance($operation->stock_item_id, $operation->warehouse_id);
+
+ return ['contract_version' => 'WAREHOUSE-UNUSED-RETURN-v1', 'writer_manifest_sha256' => WarehouseReturnMovement::MANIFEST_SHA256, 'context_revision' => app(WorkContext::class)->policy($actor)['revision'] ?? null, 'request_id' => $request->id, 'requester_id' => $request->created_by, 'work_context_key' => $request->work_context_key ?? 'procurement.internal', 'delivery' => (array) $event->getAttributes(), 'acknowledgement' => (array) $acknowledgement->getAttributes(), 'line_index' => $d['line_index'], 'operation' => (array) $operation, 'movement' => (array) $movement, 'item' => $item->only(['id', 'code', 'name', 'unit']), 'original_item' => $basis['item'], 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'quantity_milli' => $d['quantity_milli'], 'remaining_milli' => $remaining, 'book' => $book];
+ }
+
+ /**
+ * @param array $s
+ * @return array
+ */
+ private function impact(array $s): array
+ {
+ return ['physical_before' => (string) $s['book']['quantity'], 'physical_after' => (string) ($s['book']['quantity'] + $s['quantity_milli']), 'return_quantity_milli' => (string) $s['quantity_milli'], 'not_returned_before' => (string) $s['remaining_milli'], 'not_returned_after' => (string) ($s['remaining_milli'] - $s['quantity_milli']), 'original_movement_id' => (int) $s['movement']['id'], 'item' => $s['item'], 'warehouse' => $s['warehouse']];
+ }
+
+ /**
+ * @param array $d
+ * @return array
+ */
+ public function prepare(User $actor, array $d): array
+ {
+ abort_unless(($d['declared_unused'] ?? false) === true, 422, 'Declare expressamente que o material não foi consumido.');
+ $this->authorize($actor, 'prepare');
+
+ return DB::transaction(function () use ($actor, $d) {
+ $actor = $this->lock($actor, 'prepare');
+ $hash = $this->hash($d);
+ $old = SupplyRecord::query()->where('kind', 'warehouse_return')->where('operation_key', $d['operation_key'])->first();
+ if ($old) {
+ abort_unless($old->created_by === $actor->id && hash_equals($old->input_hash, $hash), 409, 'Chave utilizada com outros dados.');
+ $this->request($old->snapshot['source']['request_id']);
+
+ $state=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$old->record_key)->orderByDesc('version')->value('state');
+ return ['id' => $old->id, 'state'=>$state, 'impact' => $this->impact($old->snapshot['source']), 'replayed' => true];
+ }
+ $source = $this->source($actor, $d);
+ abort_unless($source['requester_id'] === $actor->id, 403, 'A declaração de excedente pertence ao destinatário original.');
+ $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $d['operation_key'], 'version' => 1, 'state' => 'prepared', 'stock_item_id' => $source['item']['id'], 'warehouse_id' => $source['warehouse']['id'], 'created_by' => $actor->id, 'data' => $d, 'snapshot' => ['source' => $source, 'source_hash' => $this->hash($source), 'declared_unused' => true], 'reason' => $d['reason'], 'operation_key' => $d['operation_key'], 'input_hash' => $hash, 'created_at' => now()]);
+ app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.prepared', null, null, ['record_id' => $record->id, 'request_id' => $source['request_id'], 'impact' => $this->impact($source), 'declared_unused' => true]);
+
+ return ['id' => $record->id, 'state'=>'prepared', 'impact' => $this->impact($source), 'replayed' => false];
+ });
+ }
+
+ /**
+ * @param array $d
+ * @return array
+ */
+ public function confirm(User $actor, int $id, array $d): array
+ {
+ $this->authorize($actor, 'confirm');
+
+ return DB::transaction(function () use ($actor, $id, $d) {
+ $actor = $this->lock($actor, 'confirm');
+ $p = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->lockForUpdate()->findOrFail($id);
+ $this->request($p->snapshot['source']['request_id']);
+ abort_if($p->created_by === $actor->id, 403, 'Recepção deve ser independente da declaração.');
+ $done = SupplyRecord::query()->where('kind', 'warehouse_return')->where('record_key', $p->record_key)->where('version', 2)->first();
+ if ($done) {
+ abort_unless($done->state==='confirmed',409,'Proposta encerrada sem movimento. Prepare uma nova proposta se necessário.');
+ return ['id' => $done->id, 'movement_id' => $done->data['movement_id'], 'replayed' => true];
+ }
+ abort_unless($d['version'] === 1 && $d['received_in_warehouse'] === true, 422, 'Confirme a recepção física da versão conferida.');
+ $source = $this->source($actor, $p->data);
+ abort_unless(hash_equals($p->snapshot['source_hash'], $this->hash($source)), 409, 'A origem, configuração ou livro mudou. Prepare nova conferência.');
+ $operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, 'warehouse_id' => $source['warehouse']['id'], 'stock_item_id' => $source['item']['id'], 'kind' => 'workshop_return', 'reference' => 'EXCEDENTE-'.$p->record_key, 'idempotency_key' => $p->record_key, 'payload_hash' => $p->snapshot['source_hash'], 'destination' => 'Devolução interna RA-'.$source['request_id'], 'reason' => $d['reason'], 'quantity_milli' => $source['quantity_milli'], 'theoretical_milli' => $source['book']['quantity'], 'ledger_version' => $source['book']['version'], 'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), 'stock_item' => $source['item'], 'warehouse' => $source['warehouse'], 'destination_warehouse' => null, 'equipment' => null, 'spare_part' => null, 'difference_milli' => $source['quantity_milli'], 'budget_checked' => false, 'delivery_movement_id' => (int) $source['movement']['id'], 'delivery_movement_hash' => $this->hash($source['movement']), 'received_in_warehouse' => true, 'native_request_id' => $source['request_id'], 'declaration_id' => $p->id, 'declared_unused_by' => $p->created_by, 'evidence' => $p->data['evidence']], 'recorded_at' => now()]);
+ $writer = app(WarehouseReturnMovement::class)->for($actor);
+ $out = $writer->write($operation->id, $writer->preview($operation->id));
+ $done = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $p->record_key, 'version' => 2, 'state' => 'confirmed', 'stock_item_id' => $p->stock_item_id, 'warehouse_id' => $p->warehouse_id, 'created_by' => $actor->id, 'data' => ['movement_id' => $out['movement_id'], 'operation_id' => $operation->id, 'prepared_id' => $p->id, 'received_in_warehouse' => true, 'reason' => $d['reason']], 'snapshot' => ['source' => $source, 'before' => $source['book']['quantity'], 'after' => $source['book']['quantity'] + $source['quantity_milli'], 'preparer_id' => $p->created_by], 'reason' => $d['reason'], 'operation_key' => (string) Str::uuid(), 'input_hash' => $this->hash($d), 'created_at' => now()]);
+ app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.confirmed', null, null, ['record_id' => $done->id, 'declaration_id' => $p->id, 'movement_id' => $out['movement_id'], 'impact' => $this->impact($source), 'reason' => $d['reason']]);
+ // Reuse the existing configured replenishment queue; replay never signals again.
+ app(AutomaticPurchases::class)->signal($actor, $source['item']['id'], $source['warehouse']['id']);
+
+ return ['id' => $done->id, 'movement_id' => $out['movement_id'], 'replayed' => false];
+ });
+ }
+
+ /**
+ * @param array $d
+ * @return array
+ */
+ public function close(User $actor,int $id,array $d,string $state): array
+ {
+ abort_unless(in_array($state,['withdrawn','rejected'],true),422);
+ $action=$state==='withdrawn'?'prepare':'confirm';$this->authorize($actor,$action);
+ return DB::transaction(function()use($actor,$id,$d,$state,$action){
+ $actor=$this->lock($actor,$action);$p=SupplyRecord::query()->where('kind','warehouse_return')->where('version',1)->lockForUpdate()->findOrFail($id);$this->request($p->snapshot['source']['request_id']);
+ abort_unless($state==='withdrawn'?$p->created_by===$actor->id:$p->created_by!==$actor->id,403,'Retirada pertence ao preparador; recusa exige conferência independente.');
+ $done=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$p->record_key)->where('version',2)->first();
+ if($done){abort_unless($done->state===$state,409,'A proposta já tem outra decisão terminal.');return ['id'=>$done->id,'state'=>$done->state,'replayed'=>true];}
+ abort_unless($d['version']===1,409,'Versão de proposta desactualizada.');$book=app(StockWorkflow::class)->balance($p->stock_item_id,$p->warehouse_id);
+ $closed=SupplyRecord::query()->create(['tenant_id'=>$actor->tenant_id,'kind'=>'warehouse_return','record_key'=>$p->record_key,'version'=>2,'state'=>$state,'stock_item_id'=>$p->stock_item_id,'warehouse_id'=>$p->warehouse_id,'created_by'=>$actor->id,'data'=>['prepared_id'=>$p->id,'action'=>$state,'reason'=>$d['reason']],'snapshot'=>['source'=>$p->snapshot['source'],'before'=>$book['quantity'],'after'=>$book['quantity'],'preparer_id'=>$p->created_by],'reason'=>$d['reason'],'operation_key'=>(string)Str::uuid(),'input_hash'=>$this->hash($d+['action'=>$state]),'created_at'=>now()]);
+ app(RequisitionWorkflow::class)->audit($actor,'warehouse_return.'.$state,null,null,['record_id'=>$closed->id,'declaration_id'=>$p->id,'reason'=>$d['reason'],'before'=>$book['quantity'],'after'=>$book['quantity'],'physical_effect'=>0]);
+ return ['id'=>$closed->id,'state'=>$state,'replayed'=>false];
+ });
+ }
+
+ /** @return array */
+ public function listing(User $actor, int $page, string $state): array
+ {
+ $this->authorize($actor, 'read');
+ $query = app(SupplyPlanning::class)->latest('warehouse_return');
+ if ($state !== 'all') {
+ $query->where('state', $state);
+ }
+ // Legacy and explicitly selected contexts are never mixed in this new history.
+ $context = $this->context();
+ $query->where('snapshot->source->work_context_key', $context);
+ $rows = $query->orderByDesc('id')->paginate(25, ['id', 'record_key', 'version', 'state', 'reason', 'created_by', 'created_at', 'snapshot']);
+ $prepared = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->whereIn('record_key', $rows->getCollection()->pluck('record_key'))->get()->keyBy('record_key');
+ $items = $rows->getCollection()->map(function ($r) use ($prepared) {
+ $p = $prepared->get($r->record_key);
+
+ return ['id' => $r->id, 'prepared_id' => $p->id, 'version' => $r->version, 'state' => $r->state, 'reason' => $r->reason, 'evidence' => $p->data['evidence'], 'preparer_id' => $p->created_by, 'author_id' => $r->created_by, 'created_at' => $r->created_at->toIso8601String(), 'impact' => $this->impact($r->snapshot['source'])];
+ });
+
+ return ['rows' => $items, 'pagination' => ['page' => $rows->currentPage(), 'last_page' => $rows->lastPage(), 'total' => $rows->total()]];
+ }
+}
diff --git a/app/Services/WarehouseWorkdesk.php b/app/Services/WarehouseWorkdesk.php
new file mode 100644
index 0000000..a99f16a
--- /dev/null
+++ b/app/Services/WarehouseWorkdesk.php
@@ -0,0 +1,250 @@
+tenant_id === $request->tenant_id && (
+ ($request->created_by === $actor->id && $actor->hasFunctions(['warehouse_request_prepare']))
+ || ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $request->snapshot['requester']['department_id'])
+ || ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))
+ || $actor->hasFunctions(['admin', 'warehouse_request_read']));
+ }
+
+ /** @return Builder */
+ public function visible(User $actor): Builder
+ {
+ $query = WarehouseRequest::query();
+ if (! $actor->hasFunctions(['admin', 'warehouse_request_read']) && ! ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))) {
+ $query->where(function ($q) use ($actor) {
+ $q->where('created_by', $actor->id);
+ if ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null) {
+ $q->orWhere('snapshot->requester->department_id', $actor->department_id);
+ }
+ });
+ }
+
+ return $query;
+ }
+
+ /** @param literal-string $action
+ * @return literal-string
+ */
+ private function eventCount(string $action): string
+ {
+ if (! Schema::hasTable('warehouse_request_events')) {
+ return '0';
+ }
+
+ return "(SELECT COUNT(*) FROM warehouse_request_events e WHERE e.tenant_id=warehouse_requests.tenant_id AND e.warehouse_request_id=warehouse_requests.id AND e.action='$action')";
+ }
+
+ /** SQL built solely from fixed identifiers/actions; filters use bound parameters.
+ * @return literal-string
+ */
+ public function stateExpression(): string
+ {
+ if (! Schema::hasTable('warehouse_request_events')) {
+ return "'draft'";
+ }
+ $requested = $this->quantityExpression(false);
+ $delivered = $this->quantityExpression(true);
+ $deliveries = $this->eventCount('deliver');
+ $receipts = $this->eventCount('receive');
+
+ return "CASE WHEN {$this->eventCount('cancel')}>0 THEN 'cancelled' WHEN {$this->eventCount('reject')}>0 THEN 'rejected' WHEN {$this->eventCount('authorize')}>0 THEN CASE WHEN $delivered >= $requested THEN CASE WHEN $deliveries=$receipts THEN 'closed' ELSE 'delivered' END WHEN $deliveries>0 THEN 'partial' ELSE 'authorized' END WHEN {$this->eventCount('submit')}>0 THEN 'submitted' ELSE 'draft' END";
+ }
+
+ /** @return literal-string */
+ private function quantityExpression(bool $delivery): string
+ {
+ $source = $delivery ? 'e.data' : 'warehouse_requests.snapshot';
+ if (DB::getDriverName() === 'pgsql') {
+ $json = "json_array_elements($source->'lines') j(value)";
+ $quantity = "CAST(j.value->>'quantity_milli' AS BIGINT)";
+ } elseif (DB::getDriverName() === 'sqlite') {
+ $json = "json_each(json_extract($source, '$.lines')) j";
+ $quantity = "CAST(json_extract(j.value, '$.quantity_milli') AS INTEGER)";
+ } else {
+ throw new \LogicException('A projecção de requisições suporta PostgreSQL e SQLite.');
+ }
+
+ return $delivery ? "(SELECT COALESCE(SUM($quantity),0) FROM warehouse_request_events e CROSS JOIN $json WHERE e.tenant_id=warehouse_requests.tenant_id AND e.warehouse_request_id=warehouse_requests.id AND e.action='deliver')" : "(SELECT COALESCE(SUM($quantity),0) FROM $json)";
+ }
+
+ /** @param Builder $query
+ * @return Builder
+ */
+ public function forView(Builder $query, User $actor, string $view): Builder
+ {
+ if ($view === 'all') {
+ return $query;
+ }
+ if (! Schema::hasTable('warehouse_request_events')) {
+ return $query->whereRaw('1=0');
+ }
+ $maintenance = app(OrganizationAccess::class)->maintenanceOnly($actor);
+ if ($maintenance) {
+ return $query->whereRaw('1=0');
+ }
+ $state = $this->stateExpression();
+ if (in_array($view, ['review', 'reserve'], true)) {
+ if (! $actor->hasFunctions(['warehouse_request_review']) || $actor->department_id === null) {
+ return $query->whereRaw('1=0');
+ }
+
+ return $query->where('created_by', '!=', $actor->id)->where('snapshot->requester->department_id', $actor->department_id)->whereRaw($state.($view === 'review' ? " = 'submitted'" : " IN ('authorized','partial')"));
+ }
+ if ($view === 'delivery') {
+ if (! $actor->hasFunctions(['warehouse_request_deliver']) || ! $actor->hasFunctions(['warehouse'])) {
+ return $query->whereRaw('1=0');
+ }
+
+ return $query->where('created_by', '!=', $actor->id)->whereRaw("$state IN ('authorized','partial')")->whereNotExists(fn ($q) => $q->selectRaw('1')->from('warehouse_request_events as decision')->whereColumn('decision.tenant_id', 'warehouse_requests.tenant_id')->whereColumn('decision.warehouse_request_id', 'warehouse_requests.id')->where('decision.action', 'authorize')->where('decision.actor_id', $actor->id));
+ }
+ if (! $actor->hasFunctions(['warehouse_request_prepare'])) {
+ return $query->whereRaw('1=0');
+ }
+
+ return $query->where('created_by', $actor->id)->whereRaw($this->eventCount('deliver').'>'.$this->eventCount('receive'));
+ }
+
+ /** @param Builder $base
+ * @return list
+ */
+ public function views(Builder $base, User $actor): array
+ {
+ $views = ['all' => 'Todos no meu âmbito'];
+ if (! app(OrganizationAccess::class)->maintenanceOnly($actor)) {
+ if ($actor->hasFunctions(['warehouse_request_review'])) {
+ $views += ['review' => 'Para autorizar', 'reserve' => 'Reservas a acompanhar'];
+ }
+ if ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse'])) {
+ $views['delivery'] = 'Entregas pendentes';
+ }
+ if ($actor->hasFunctions(['warehouse_request_prepare'])) {
+ $views['receipt'] = 'Recepções por confirmar';
+ }
+ }
+ $rows = [];
+ foreach ($views as $key => $label) {
+ $rows[] = ['key' => $key, 'label' => $label, 'total' => $this->forView(clone $base, $actor, $key)->count()];
+ }
+
+ return $rows;
+ }
+
+ /** @param array $p
+ * @return array
+ */
+ public function actions(User $actor, WarehouseRequest $r, array $p): array
+ {
+ $active = ! app(OrganizationAccess::class)->maintenanceOnly($actor);
+ $owner = $r->created_by === $actor->id;
+ $prepare = $actor->hasFunctions(['warehouse_request_prepare']);
+ $review = $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $r->snapshot['requester']['department_id'] && ! $owner;
+ $open = in_array($p['state'], ['authorized', 'partial'], true);
+
+ return ['canSubmit' => $active && $owner && $prepare && $p['state'] === 'draft', 'canReview' => $active && $review && $p['state'] === 'submitted',
+ 'canDeliver' => $active && ! $owner && $p['reviewer_id'] !== $actor->id && $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']) && $open,
+ 'canManageReservation' => $active && $review && $open, 'canReceive' => $active && $owner && $prepare];
+ }
+
+ /** @param array $p
+ * @return array
+ */
+ public function health(WarehouseRequest $r, array $p): array
+ {
+ $rows = [];
+ $needsReserve = false;
+ $needsCheck = false;
+ $readyLines = 0;
+ $open = in_array($p['state'], ['authorized', 'partial'], true);
+ foreach ($r->snapshot['lines'] as $index => $line) {
+ $item = StockItem::query()->whereKey($line['item']['id'])->first();
+ $sourceChanged = $item === null || $item->only(['id', 'code', 'name', 'unit']) !== $line['item'];
+ if ($line['equipment'] !== null) {
+ $equipment = Equipment::query()->whereKey($line['equipment']['id'])->first();
+ $sourceChanged = $sourceChanged || $equipment === null || $equipment->only(['id', 'code', 'description']) !== $line['equipment'] || $equipment->department_id !== $r->snapshot['requester']['department_id'];
+ }
+ $part = SparePart::query()->where('stock_item_id', $line['item']['id'])->first();
+ if ($part !== null && ($line['equipment'] === null || ! EquipmentSparePart::query()->where('spare_part_id', $part->id)->where('equipment_id', $line['equipment']['id'])->exists())) {
+ $sourceChanged = true;
+ }
+ $gap = $open ? max(0, $p['remaining'][$index] - $p['reserved'][$index]) : 0;
+ $issue = null;
+ $ready = false;
+ if ($open) {
+ $lots = app(InventoryLots::class)->context($line['item']['id'], $r->warehouse_id);
+ $usable = $lots === null ? app(StockWorkflow::class)->balance($line['item']['id'], $r->warehouse_id)['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null);
+ $commitment = app(WarehouseReservations::class)->committed($line['item']['id'], $r->warehouse_id);
+ if ($sourceChanged) {
+ $issue = 'O cadastro ou destino mudou; é necessária revisão da origem.';
+ } elseif ($usable === null) {
+ $issue = 'Reconciliar lotes antes de actualizar a reserva ou entregar.';
+ } elseif ($p['reserved'][$index] > 0 && $usable < $commitment) {
+ $issue = 'O artigo tem material comprometido indisponível; conferir qualidade, validade e reservas.';
+ } elseif (! $p['reservation_ready']) {
+ $issue = 'Reserva física por instalar.';
+ } elseif ($gap > 0) {
+ $issue = 'Saldo ainda sem reserva; rever reposição e actualizar a reserva autorizadamente.';
+ }
+ $ready = ! $sourceChanged && $usable !== null && $usable >= $commitment && $p['reserved'][$index] > 0;
+ $needsReserve = $needsReserve || $gap > 0;
+ $needsCheck = $needsCheck || $sourceChanged || $usable === null || ($p['reserved'][$index] > 0 && $usable < $commitment);
+ }
+ if ($ready) {
+ $readyLines++;
+ }
+ $rows[] = ['index' => $index, 'unreserved_milli' => $gap, 'attention' => $issue, 'source_changed' => $sourceChanged, 'ready' => $ready];
+ }
+ $unreceived = count(array_filter($p['events'], fn ($e) => $e['action'] === 'deliver' && ! in_array($e['id'], $p['received_deliveries'], true)));
+
+ return ['lines' => $rows, 'needs_reservation' => $needsReserve, 'needs_check' => $needsCheck, 'unconfirmed_deliveries' => $unreceived,
+ 'delivery_ready' => $open && ! in_array(true, array_column($rows, 'source_changed'), true) && ($p['partial_allowed'] ? $readyLines > 0 : ($readyLines === count($rows) && ! $needsReserve))];
+ }
+
+ /** Current eligible people, not assignments or new grants.
+ * @param array $p
+ * @return array>
+ */
+ public function people(WarehouseRequest $r, array $p): array
+ {
+ $result = ['requester' => [], 'review' => [], 'delivery' => []];
+ foreach (User::query()->where('tenant_id', $r->tenant_id)->orderBy('name')->get(['id', 'tenant_id', 'name', 'department_id', 'purchasing_role']) as $user) {
+ if (app(OrganizationAccess::class)->maintenanceOnly($user)) {
+ continue;
+ }
+ $context = app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null;
+ $permissions = app(OrganizationAccess::class)->permissions($user, $context);
+ if ($context !== null && ! in_array($context, app(WorkContext::class)->available($user), true)) {
+ continue;
+ }
+ $person = $user->only(['id', 'name']);
+ if ($user->id === $r->created_by && in_array('warehouse_request_prepare', $permissions, true)) {
+ $result['requester'][] = $person;
+ }
+ if ($user->id !== $r->created_by && $user->department_id === $r->snapshot['requester']['department_id'] && in_array('warehouse_request_review', $permissions, true)) {
+ $result['review'][] = $person;
+ }
+ if ($user->id !== $r->created_by && $user->id !== $p['reviewer_id'] && in_array('warehouse_request_deliver', $permissions, true) && in_array('warehouse', $permissions, true)) {
+ $result['delivery'][] = $person;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/app/Services/WorkContext.php b/app/Services/WorkContext.php
index bed81f1..8e0b843 100644
--- a/app/Services/WorkContext.php
+++ b/app/Services/WorkContext.php
@@ -63,21 +63,11 @@ class WorkContext
/** @return list */
public function available(User $user): array
{
- $enabled = $this->policy($user)['enabled'] ?? [];
- $granted = [];
if (app(OrganizationAccess::class)->active($user->tenant_id)) {
- $profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
- ->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
- ->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.work_contexts', 'p.permissions']);
- foreach ($profiles as $profile) {
- $permissions = json_decode($profile->permissions, true, 32, JSON_THROW_ON_ERROR);
- foreach (json_decode($profile->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR) as $key) {
- if ($this->functional($permissions, $key)) {
- $granted[] = $key;
- }
- }
- }
- } elseif (app()->environment(['testing', 'payable-testing'])) {
+ return $this->configuredAvailable($user);
+ }
+ $granted = [];
+ if (app()->environment(['testing', 'payable-testing'])) {
// Development roles remain usable locally; never become customer grants.
foreach (array_keys(self::OPTIONS) as $key) {
if ($this->functional(app(OrganizationAccess::class)->permissions($user), $key)) {
@@ -86,7 +76,31 @@ class WorkContext
}
}
- return array_values(array_intersect($this->purchased($user), $enabled, array_unique($granted)));
+ return array_values(array_intersect($this->purchased($user), $this->policy($user)['enabled'] ?? [], array_unique($granted)));
+ }
+
+ /** Same configured intersection used after activation, without enabling it.
+ * @return list
+ */
+ public function configuredAvailable(User $user): array
+ {
+ if (! Schema::hasTable('organization_memberships') || ! Schema::hasColumn('organization_profiles', 'work_contexts')) {
+ return [];
+ }
+ $granted = [];
+ $profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
+ ->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
+ ->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.work_contexts', 'p.permissions']);
+ foreach ($profiles as $profile) {
+ $permissions = json_decode($profile->permissions, true, 32, JSON_THROW_ON_ERROR);
+ foreach (json_decode($profile->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR) as $key) {
+ if ($this->functional($permissions, $key)) {
+ $granted[] = $key;
+ }
+ }
+ }
+
+ return array_values(array_intersect($this->purchased($user), $this->policy($user)['enabled'] ?? [], array_unique($granted)));
}
/** @param list $permissions */
diff --git a/app/Services/WorkflowTaskInbox.php b/app/Services/WorkflowTaskInbox.php
new file mode 100644
index 0000000..b758bee
--- /dev/null
+++ b/app/Services/WorkflowTaskInbox.php
@@ -0,0 +1,331 @@
+> */
+ public function build(User $actor, string $area): array
+ {
+ abort_unless(auth()->id() === $actor->id && in_array($area, ['procurement', 'people'], true), 403);
+ abort_unless($area !== 'people' || app(PeopleHiring::class)->kinds($actor) !== [], 403);
+ if (app(OrganizationAccess::class)->maintenanceOnly($actor)) {
+ return [];
+ }
+ $rows = $area === 'people' ? $this->people($actor) : [...$this->purchases($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->finance($actor)];
+ usort($rows, fn ($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '') ?: strnatcmp($a['id'], $b['id']));
+
+ return $rows;
+ }
+
+ /** @return list> */
+ private function purchases(User $actor): array
+ {
+ $rows = [];
+ $prepare = app(OrganizationAccess::class)->canRequest($actor);
+ $query = Requisition::query()->visibleTo($actor)->where(function ($q) use ($actor, $prepare) {
+ $q->where('status', 'pending');
+ if ($prepare) {
+ $q->orWhere(fn ($draft) => $draft->where('status', 'draft')->where('requester_id', $actor->id));
+ }
+ });
+ foreach ($query->with('steps')->lazyById(100) as $record) {
+ $draft = $record->status === 'draft' && $prepare && $record->requester_id === $actor->id;
+ if (! $draft && ! $record->canDecide($actor)) {
+ continue;
+ }
+ $step = $draft ? null : $record->steps->firstWhere('decision', 'pending');
+ $rows[] = ['id' => 'purchase:'.$record->id.':'.($step->id ?? 'draft'), 'module' => 'purchases',
+ 'reference' => $record->number, 'label' => $draft ? 'Conferir e submeter requisição de compra' : 'Decidir a etapa de aprovação',
+ 'task' => $draft ? 'prepare' : 'approve', 'status' => $record->status, 'blocked' => false,
+ 'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$record->id];
+ }
+
+ return $rows;
+ }
+
+ /** @return list> */
+ private function warehouse(User $actor): array
+ {
+ if (! Schema::hasTable('warehouse_requests') || ! Schema::hasTable('warehouse_request_events')) {
+ return [];
+ }
+ $desk = app(WarehouseWorkdesk::class);
+ $rows = [];
+ foreach (['review', 'reserve', 'delivery', 'receipt'] as $view) {
+ $query = $desk->forView($desk->visible($actor), $actor, $view);
+ foreach ($query->lazyById(100) as $record) {
+ $progress = app(WarehouseFulfilment::class)->progress($record);
+ $health = in_array($view, ['reserve', 'delivery'], true) ? $desk->health($record, $progress) : null;
+ if ($view === 'reserve' && ! $health['needs_reservation'] && ! $health['needs_check']) {
+ continue;
+ }
+ $base = ['module' => 'warehouse', 'reference' => 'RA-'.$record->id, 'status' => $progress['state'],
+ 'created_at' => $this->date($record->created_at), 'due_on' => null];
+ if ($view === 'receipt') {
+ foreach ($progress['events'] as $event) {
+ if ($event['action'] === 'deliver' && ! in_array($event['id'], $progress['received_deliveries'], true)) {
+ $rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':receive:'.$event['id'], 'task' => 'receive',
+ 'label' => 'Confirmar recepção da entrega #'.$event['id'], 'blocked' => false,
+ 'created_at' => $this->date($event['created_at']), 'href' => '/stock/requisicoes/'.$record->id.'#warehouse-command-receive-'.$event['id']];
+ }
+ }
+
+ continue;
+ }
+ $action = $view === 'review' ? 'authorize' : ($view === 'delivery' ? 'deliver' : 'reserve');
+ $blocked = $view === 'delivery' && ! $health['delivery_ready'];
+ $rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':'.$view, 'task' => $view,
+ 'label' => match ($view) {
+ 'review' => 'Autorizar ou recusar requisição interna',
+ 'reserve' => 'Conferir reposição e actualizar reserva',
+ default => $blocked ? 'Conferir stock antes da entrega' : 'Confirmar a entrega física',
+ }, 'blocked' => $blocked,
+ 'href' => '/stock/requisicoes/'.$record->id.($blocked ? '' : '#warehouse-command-'.$action)];
+ }
+ }
+
+ return $rows;
+ }
+
+ /** @return list> */
+ private function people(User $actor): array
+ {
+ $kinds = app(PeopleHiring::class)->kinds($actor);
+ abort_unless($kinds !== [], 403);
+ if (! Schema::hasTable('people_processes') || ! Schema::hasTable('people_process_access')) {
+ return [];
+ }
+ $query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor);
+ $desk = app(PeopleWorkdesk::class)->build(clone $query, $actor);
+ $dates = (clone $query)->pluck('created_at', 'id');
+ $rows = [];
+ foreach ([...$desk['tasks'], ...$desk['approvals']] as $task) {
+ $rows[] = ['id' => 'people:'.$task['id'], 'module' => 'people', 'reference' => $task['reference'], 'label' => $task['label'],
+ 'task' => $task['action'], 'status' => $task['status'], 'blocked' => false,
+ 'created_at' => $this->date($dates->get($task['process_id'])), 'due_on' => $task['due_on'], 'href' => $task['href']];
+ }
+
+ return $rows;
+ }
+
+ /** @return list> */
+ private function finance(User $actor): array
+ {
+ if (! $actor->hasFunctions(['finance'])) {
+ return [];
+ }
+ $rows = [];
+ foreach (Invoice::query()->where('status', 'pending')->lazyById(100) as $invoice) {
+ if (! app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)) {
+ continue;
+ }
+ $rows[] = ['id' => 'invoice:'.$invoice->id, 'module' => 'finance', 'reference' => $invoice->number,
+ 'label' => 'Conferir factura de fornecedor', 'task' => 'invoice_review', 'status' => $invoice->status, 'blocked' => false,
+ 'created_at' => $this->date($invoice->created_at), 'due_on' => null, 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id];
+ }
+ foreach (ExternalPayment::query()->where('status', 'pending')->lazyById(100) as $payment) {
+ if (! app(ExternalPaymentWorkflow::class)->reviewerEligible($actor, $payment)) {
+ continue;
+ }
+ $invoice = Invoice::query()->whereKey($payment->invoice_id)->firstOrFail();
+ $rows[] = ['id' => 'payment:'.$payment->id, 'module' => 'finance', 'reference' => $invoice->number,
+ 'label' => 'Conferir registo de pagamento externo', 'task' => 'payment_review', 'status' => $payment->status, 'blocked' => false,
+ 'created_at' => $this->date($payment->created_at), 'due_on' => null, 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id];
+ }
+
+ return $rows;
+ }
+
+ /** @return list> */
+ private function awards(User $actor): array
+ {
+ if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
+ return [];
+ }
+ $workflow = app(PurchaseWorkflow::class);
+ $rows = [];
+ $requests = Requisition::query()->visibleTo($actor);
+ $query = PurchaseAward::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereIn('status', ['pending', 'approved']);
+ foreach ($query->lazyById(100) as $award) {
+ $request = (clone $requests)->whereKey($award->requisition_id)->firstOrFail();
+ $decide = $workflow->canDecide($actor, $award, $request);
+ if (! $decide && ! $workflow->canIssue($actor, $award, $request)) {
+ continue;
+ }
+ $step = $decide ? $award->approvals()->where('decision', 'pending')->orderBy('position')->first() : null;
+ $rows[] = ['id' => 'award:'.$award->id.':'.($step->id ?? 'issue'), 'module' => 'purchases', 'reference' => $request->number,
+ 'label' => $decide ? 'Decidir a etapa da adjudicação' : 'Conferir adjudicação para emitir ordem de compra',
+ 'task' => $decide ? 'award_approve' : 'order_issue_review', 'status' => $award->status, 'blocked' => false,
+ 'created_at' => $this->date($award->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$request->id.'/adjudicacao'];
+ }
+
+ return $rows;
+ }
+
+ private function date(mixed $value): ?string
+ {
+ return $value === null ? null : CarbonImmutable::parse($value)->utc()->toIso8601String();
+ }
+
+ /** @return list> */
+ private function formalReviews(User $actor): array
+ {
+ if (! $actor->hasFunctions(['finance', 'department_head'])) {
+ return [];
+ }
+ $rows = [];
+ $admission = app(FormalAdmission::class);
+ $recommendation = app(FormalRecommendation::class);
+ $requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
+ foreach ($requests->lazyById(100) as $request) {
+ $base = ['module' => 'purchases', 'reference' => $request->number, 'blocked' => false, 'status' => 'pending', 'due_on' => null];
+ $adoption = $admission->adoption($request);
+ if ($adoption !== null) {
+ $decisions = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->whereNotExists(fn ($query) => $query->selectRaw('1')->from('admission_decisions as newer')
+ ->whereColumn('newer.tenant_id', 'admission_decisions.tenant_id')->whereColumn('newer.admission_adoption_id', 'admission_decisions.admission_adoption_id')
+ ->whereColumn('newer.quotation_id', 'admission_decisions.quotation_id')->whereColumn('newer.revision', '>', 'admission_decisions.revision'));
+ foreach ($decisions->lazyById(100) as $decision) {
+ if ($admission->canReview($actor, $request, $decision)) {
+ $rows[] = [...$base, 'id' => 'admission:'.$decision->id.':review', 'task' => 'admission_review',
+ 'label' => 'Conferir proposta de admissão formal', 'created_at' => $this->date($decision->created_at),
+ 'href' => '/requisicoes/'.$request->id.'/admissao?version='.$adoption->id.'&decision_id='.$decision->id.'#admission-'.$decision->id];
+ }
+ }
+ }
+ $adoption = $recommendation->adoption($request);
+ $attempt = $adoption === null ? null : RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
+ if ($adoption !== null && $attempt !== null && $recommendation->canReview($actor, $request, $attempt)) {
+ $rows[] = [...$base, 'id' => 'recommendation:'.$attempt->id.':review', 'task' => 'recommendation_review',
+ 'label' => 'Conferir recomendação formal de adjudicação', 'created_at' => $this->date($attempt->created_at),
+ 'href' => '/requisicoes/'.$request->id.'/recomendacao?version='.$adoption->id.'&attempt_id='.$attempt->id.'#recommendation-'.$attempt->id];
+ }
+ }
+
+ return $rows;
+ }
+
+ /** Only a person's own unfinished analysis, never a new mandatory analyst.
+ * @return list>
+ */
+ private function documentary(User $actor): array
+ {
+ if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
+ return [];
+ }
+ $rows = [];
+ $workflow = app(DocumentaryQualificationWorkflow::class);
+ $requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
+ $query = QualificationSet::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_sets as newer')
+ ->whereColumn('newer.tenant_id', 'qualification_sets.tenant_id')->whereColumn('newer.requisition_id', 'qualification_sets.requisition_id')->whereColumn('newer.revision', '>', 'qualification_sets.revision'));
+ foreach ($query->lazyById(100) as $set) {
+ $request = (clone $requests)->whereKey($set->requisition_id)->firstOrFail();
+ if (! $workflow->canAssess($actor, $request, $set)) {
+ continue;
+ }
+ $records = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('analyst_id', $actor->id)->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_assessments as newer')
+ ->whereColumn('newer.tenant_id', 'qualification_assessments.tenant_id')->whereColumn('newer.qualification_set_id', 'qualification_assessments.qualification_set_id')
+ ->whereColumn('newer.analyst_id', 'qualification_assessments.analyst_id')->whereColumn('newer.quotation_id', 'qualification_assessments.quotation_id')->whereColumn('newer.revision', '>', 'qualification_assessments.revision'));
+ foreach ($records->lazyById(100) as $record) {
+ if (! collect($record->answers)->contains('result', 'pending')) {
+ continue;
+ }
+ $rows[] = ['id' => 'qualification:'.$record->id.':complete', 'module' => 'purchases', 'reference' => $request->number,
+ 'label' => 'Completar a minha análise documental', 'task' => 'qualification_complete', 'status' => 'pending', 'blocked' => false,
+ 'created_at' => $this->date($record->created_at), 'due_on' => null,
+ 'href' => '/requisicoes/'.$request->id.'/qualificacao?version='.$set->id.'"ation_id='.$record->quotation_id.'#qualification-assess'];
+ }
+ }
+
+ return $rows;
+ }
+
+ /** @return list> */
+ private function supply(User $actor): array
+ {
+ $planning = app(SupplyPlanning::class);
+ if (! Schema::hasTable('supply_records') || ! $planning->allows($actor, 'supply_review')) {
+ return [];
+ }
+ $rows = [];
+ foreach (['agreement', 'policy', 'auto_rule'] as $kind) {
+ foreach ($planning->latest($kind)->whereIn('state', ['draft', 'returned'])->lazyById(100) as $record) {
+ $eligible = $kind === 'auto_rule' ? app(AutomaticPurchases::class)->canReview($actor, $record) : $planning->canReview($actor, $record);
+ if (! $eligible) {
+ continue;
+ }
+ $rows[] = ['id' => 'supply:'.$record->id.':review', 'module' => 'supply',
+ 'reference' => trim(($record->snapshot['item']['code'] ?? 'Fornecimento').' '.($record->snapshot['warehouse']['code'] ?? '')),
+ 'label' => match ($kind) {
+ 'agreement' => 'Conferir acordo de fornecimento', 'policy' => 'Conferir política de reposição', default => 'Conferir parametrização de compra automática',
+ }, 'task' => $kind.'_review', 'status' => $record->state, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null,
+ 'href' => $kind === 'auto_rule' ? '/stock/reposicao?rule_id='.$record->id.'#supply-rule-'.$record->id
+ : '/compras/fornecimento?view='.($kind === 'agreement' ? 'agreements' : 'calendar').'&record_id='.$record->id.'#supply-record-'.$record->id];
+ }
+ }
+
+ return $rows;
+ }
+
+ /** @return list> */
+ private function quotations(User $actor): array
+ {
+ if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
+ return [];
+ }
+ $access = app(QuotationParticipation::class);
+ $rows = [];
+ $requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')
+ ->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
+ foreach ($requests->lazyById(100) as $request) {
+ $prepare = $access->canPrepare($actor, $request);
+ if (! $prepare && ! $access->canEvaluateProcess($actor, $request)) {
+ continue;
+ }
+ $comparison = app(ProposalComparison::class)->inspect($request);
+ $path = '/requisicoes/'.$request->id.'/cotacoes';
+ $base = ['module' => 'purchases', 'reference' => $request->number, 'status' => $request->status, 'blocked' => false,
+ 'created_at' => $this->date($request->created_at), 'due_on' => null];
+ if ($prepare) {
+ if (count($comparison['rows']) < $comparison['policy']['minimum_proposals']) {
+ $rows[] = [...$base, 'id' => 'quotation:'.$request->id.':prepare', 'task' => 'quotation_prepare',
+ 'label' => 'Completar consulta ao mercado', 'href' => $path.'?prepare=1#quotation-create'];
+ } elseif (! $comparison['ready'] && collect($comparison['rows'])->contains('eligible', false)) {
+ $rows[] = [...$base, 'id' => 'quotation:'.$request->id.':check', 'task' => 'quotation_check',
+ 'label' => 'Conferir propostas ou impedimentos da comparação', 'href' => $path];
+ } elseif ($comparison['ready'] && $actor->id !== $request->requester_id) {
+ $rows[] = [...$base, 'id' => 'quotation:'.$request->id.':result', 'task' => 'comparison_review',
+ 'label' => 'Conferir comparação e requisitos da adjudicação', 'href' => '/requisicoes/'.$request->id.'/adjudicacao'];
+ }
+ }
+ $quotes = Quotation::query()->where('requisition_id', $request->id)->with('supplier')->get()->keyBy('id');
+ foreach ($comparison['rows'] as $row) {
+ $quote = $quotes->get($row['id']);
+ if (! $row['eligible'] || $row['evaluation_count'] >= $comparison['policy']['minimum_evaluators'] || $quote === null || ! $access->canEvaluate($actor, $request, $quote)) {
+ continue;
+ }
+ $rows[] = [...$base, 'id' => 'quotation:'.$quote->id.':evaluate', 'task' => 'quotation_evaluate',
+ 'label' => 'Avaliar proposta '.$quote->reference, 'created_at' => $this->date($quote->created_at),
+ 'href' => $path.'?quotation_id='.$quote->id.'#quotation-'.$quote->id];
+ }
+ }
+
+ return $rows;
+ }
+}
diff --git a/app/Services/WorkflowTasks.php b/app/Services/WorkflowTasks.php
index d104d4e..75bcdd9 100644
--- a/app/Services/WorkflowTasks.php
+++ b/app/Services/WorkflowTasks.php
@@ -9,10 +9,15 @@ class WorkflowTasks
public function catalogue(): array
{
$specific = [
+ 'supply_prepare' => ['supply.prepare' => 'Preparar acordos, políticas e parametrização da compra automática; reavaliar necessidades'],
+ 'supply_review' => ['supply.review' => 'Conferir políticas e activar ou suspender parametrizações preparadas por outra pessoa'],
+ 'warehouse_request_review' => ['warehouse.request.review' => 'Decidir requisições internas do departamento, actualizar reservas e cancelar saldos pendentes'],
+ 'warehouse_request_prepare' => ['warehouse.request.prepare' => 'Preparar e submeter requisições internas próprias; acompanhar reservas e confirmar a recepção das entregas'],
+ 'warehouse_request_deliver' => ['warehouse.request.deliver' => 'Acompanhar entregas pendentes e confirmar a saída efectiva, em conjunto com a tarefa de armazém e com independência do requisitante e autorizador'],
'requester' => ['requisition.prepare' => 'Preparar e submeter requisições próprias; acompanhar decisões'],
- 'department_head' => ['requisition.approve.department_head' => 'Decidir a etapa de aprovação do próprio departamento'],
- 'procurement' => ['requisition.approve.procurement' => 'Decidir a etapa de Compras quando exigida pelo percurso', 'procurement.prepare' => 'Preparar e acompanhar consulta ao mercado e aquisição'],
- 'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Exercer as conferências e operações financeiras permitidas pelo perfil'],
+ 'department_head' => ['requisition.approve.department_head' => 'Decidir as etapas previstas de requisição e adjudicação do próprio departamento; analisar requisitos documentais e conferir admissão/recomendação quando previsto no regime adoptado, com independência'],
+ 'procurement' => ['requisition.approve.procurement' => 'Decidir as etapas previstas de Compras na requisição e adjudicação', 'procurement.prepare' => 'Preparar consulta, requisitos documentais e propostas formais conforme os regimes adoptados; avaliar propostas elegíveis e conferir a emissão da ordem após as aprovações exigidas'],
+ 'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Analisar requisitos documentais e conferir admissão/recomendação conforme o regime adoptado; conferir facturas e registos de pagamentos externos de outra pessoa, com revalidação dos conflitos e competências específicas'],
'people_service_prepare' => ['people.service.prepare' => 'Preparar processo, candidaturas, proposta de selecção e minuta de prestador individual'],
'people_staff_prepare' => ['people.staff.prepare' => 'Preparar processo, candidaturas, proposta de selecção e minuta de recrutamento'],
'people_service_review' => ['people.service.review' => 'Conferir abertura, avaliar e decidir selecção de prestador individual conforme a equipa atribuída'],
diff --git a/config/replenishment.php b/config/replenishment.php
new file mode 100644
index 0000000..ceb22fe
--- /dev/null
+++ b/config/replenishment.php
@@ -0,0 +1,7 @@
+ max(1, (int) env('SGC_REPLENISHMENT_SCAN_MINUTES', 5)),
+ 'batch_size' => max(1, min(500, (int) env('SGC_REPLENISHMENT_BATCH_SIZE', 100))),
+];
diff --git a/config/stock_read.php b/config/stock_read.php
new file mode 100644
index 0000000..3197e88
--- /dev/null
+++ b/config/stock_read.php
@@ -0,0 +1,6 @@
+ [],
+ 'qa_run' => env('SGF_ISOLATED_REGRESSION_RUN'),
+ 'package_manifest_sha256' => 'bbd97f63b0eeb671bd3ad57bbd62bd34fcf67b8e943320423715d514a2a2c19c',
+];
diff --git a/config/stock_returns.php b/config/stock_returns.php
new file mode 100644
index 0000000..5735ca2
--- /dev/null
+++ b/config/stock_returns.php
@@ -0,0 +1,3 @@
+ false, 'lock_timeout_ms' => 2000];
diff --git a/database/migrations/2026_11_02_000049_create_warehouse_reservations.php b/database/migrations/2026_11_02_000049_create_warehouse_reservations.php
index 35c52ab..fd9fc4c 100644
--- a/database/migrations/2026_11_02_000049_create_warehouse_reservations.php
+++ b/database/migrations/2026_11_02_000049_create_warehouse_reservations.php
@@ -35,6 +35,6 @@ return new class extends Migration
public function down(): void
{
- Schema::dropIfExists('warehouse_reservations');
+ throw new LogicException('Preservar o histórico de reservas e entregas.');
}
};
diff --git a/database/migrations/2026_11_02_000050_create_replenishment_work.php b/database/migrations/2026_11_02_000050_create_replenishment_work.php
new file mode 100644
index 0000000..91133f2
--- /dev/null
+++ b/database/migrations/2026_11_02_000050_create_replenishment_work.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('tenant_id')->constrained()->restrictOnDelete();
+ $table->foreignId('stock_item_id')->constrained()->restrictOnDelete();
+ $table->foreignId('warehouse_id')->constrained()->restrictOnDelete();
+ $table->foreignId('responsible_id')->constrained('users')->restrictOnDelete();
+ $table->unsignedBigInteger('generation')->default(1);
+ $table->unsignedBigInteger('processed_generation')->default(0);
+ $table->unsignedInteger('attempts')->default(0);
+ $table->unsignedInteger('failures')->default(0);
+ $table->string('last_outcome')->nullable();
+ $table->text('last_error')->nullable();
+ $table->timestampTz('due_at');
+ $table->timestampTz('last_run_at')->nullable();
+ $table->timestampsTz();
+ $table->unique(['tenant_id', 'stock_item_id', 'warehouse_id']);
+ $table->index(['due_at', 'id']);
+ });
+ }
+
+ public function down(): void
+ {
+ throw new LogicException('Preservar trabalhos de reposição pendentes e histórico de recuperação.');
+ }
+};
diff --git a/docs/common-issue-workshop-delivery.md b/docs/common-issue-workshop-delivery.md
new file mode 100644
index 0000000..0ec19bd
--- /dev/null
+++ b/docs/common-issue-workshop-delivery.md
@@ -0,0 +1,11 @@
+# Saída física comum e ligação sintética de Oficina
+
+02/11/2026. `StockWorkflow` delega a saída normal em `StockIssueMovement` e no artefacto local verificado `packages/inventory-issue/releases/0.1.0`, fonte `sgf-tempo/packages/inventory-issue`. Manifesto SHA-256 `3c24d9e9fb99bfbb274077495c3a42ddb7eccab06b5c6a956cd739b2bc9b56f0`.
+
+O núcleo exige transacção do host, autorização, fotografia/revisão actual, bloqueio de armazém/artigo e quantidade física suficiente; repetições recuperam o movimento. Não substitui políticas de Compras, reservas, FEFO/lotes, equipamentos, sinais ou auditoria. Transferências, contagens e estornos permanecem no circuito existente. Não há dependência da pasta SGF em runtime.
+
+Provas afectadas em bases PostgreSQL sintéticas novas: **25 testes/125 asserções**, mais **2 testes/10 asserções** seleccionados de lotes (FEFO e bloqueios). Provas de recepção anteriores reutilizadas, sem repetir suites alheias. Ver relatório SGF `docs/WORKSHOP_STOCK_DELIVERY.md` e prova `docs/proofs/workshop-stock-20261102.json` no repositório SGF para a ligação com conferência independente, repetição, falha/retoma, versões e concorrência real.
+
+O SGF usa um livro novo QA sem lotes/reservas/peças e apenas ordens explicitamente elegíveis. Uma entrega já debitada sem reconciliação impede novo abatimento. Não foi entregue reconciliação de entregas, devoluções ou compensações. A demonstração de recepção anterior é separada do cenário de consumo; não se declara um ERP integral instalado ou capacidade de implantação.
+
+Sem alteração das demos, candidata activada, dados/direitos reais, publicação ou checkpoint. Os três direitos SGF `workshop_stock.view/prepare/confirm` são apenas propostos para implantação; as concessões da regressão são temporárias e restritas a actores QA novos.
diff --git a/docs/common-receipt-stock-delivery.md b/docs/common-receipt-stock-delivery.md
new file mode 100644
index 0000000..cba4bef
--- /dev/null
+++ b/docs/common-receipt-stock-delivery.md
@@ -0,0 +1,13 @@
+# Recepção → escritor comum de Stock
+
+02/11/2026. `ReceiptWorkflow` passou a delegar a gravação de entradas físicas no adaptador `ReceiptStockMovement` e no artefacto local verificado `packages/inventory-write/releases/0.1.0`, fonte única `sgf-tempo/packages/inventory-write`. Nenhum fallback cria uma segunda entrada. Não se alteraram as validações de origem/quantidade/qualidade/unidade, separação do recebedor, lotes, sinais e auditoria do workflow.
+
+Hash de origem congelado/revalidado, linha bloqueada, unicidade existente por `goods_receipt_item_id` e participação na mesma transacção; repetição recupera o movimento e falha desfaz os efeitos. A fotografia conserva os campos e acrescenta referência/hash. Não reescreve movimentos anteriores nem executa importação histórica. O escritor não constitui uma API independente para fabricar recepções confirmadas.
+
+Manifesto SHA-256 `f0b99e591f9a4df135e685f64ceffad77bed259222debe0f34c20f95e3cad800`. Artefacto e fonte comparados; sem dependência da pasta SGF no runtime. A versão é do writer, não uma candidata Purchasing activada.
+
+Provas PostgreSQL isoladas: fluxo novo **1/33**, recepções afectadas **31/193**, lotes seleccionados **3/20**, replay com adaptador final **1/8**. O SGF consultou o mesmo movimento através de role QA SELECT-only: **1/31**, mais os seus casos funcionais **2/77**. Exemplo: receber 6, aceitar 4, rejeitar 2; posterior recepção de 2 totalmente rejeitada; saldo 4, um movimento e duas auditorias de recepção. Concorrência recuperou o mesmo ID; falha após INSERT fez rollback e recuperou sem duplicação.
+
+Sem dados/regras/demos reais alterados, migração ou direito público, activação, publicação ou checkpoint. Bases QA retidas; credenciais/tokens privados. O fluxo de preparação/confirmação de recepções no ERP SGF ainda não foi transferido; a observação de um livro QA não é implantação ou conector remoto obrigatório. Escritas em volume, outros drivers, revisão visual e capacidade de implantação permanecem pendentes.
+
+Relatório e prova no SGF: `docs/STOCK_RECEIPT_LINK_DELIVERY.md` e `docs/proofs/stock-receipt-link-20261102.json`. Os restantes movimentos nativos permanecem nos seus serviços; próximo ponto é consumo de oficina por origem, sem duplicar saída.
diff --git a/docs/common-workshop-material-return-artifact.md b/docs/common-workshop-material-return-artifact.md
new file mode 100644
index 0000000..1a8e07c
--- /dev/null
+++ b/docs/common-workshop-material-return-artifact.md
@@ -0,0 +1,11 @@
+# Artefacto comum de entrada por devolução de excedente
+
+Continuação posterior: [percurso nativo de devolução](warehouse-unused-return.md) implementado e comprovado em base isolada, reutilizando este mesmo artefacto sem alteração. A descrição inicial abaixo permanece como registo da preparação; não representa o estado posterior da integração nativa.
+
+02/11/2026. Preparado localmente `packages/inventory-return/releases/0.1.0`, fonte canónica SGF `packages/inventory-return`. Manifesto SHA-256 `2850a4f5cb678acfe3a9dbc7a7d62eb233bc72722ad0de374681c2679c90557c`.
+
+O escritor devolve quantidade ao mesmo livro, com transacção/autorização do host, operação e entrega original íntegras, revisão, bloqueios, quantidade cumulativa e recepção física expressa. É exercitado pelo adaptador sintético Oficina–Stock do SGF. A regra de excedente não consumido permanece no host, que conhece as associações de consumo.
+
+**O comando nativo de devolução de Compras não está integrado por este artefacto.** Os circuitos existentes de devolução a fornecedor, abate e estorno têm efeitos e autoridades diferentes e não foram reutilizados indevidamente para gerar entradas de excedente de Oficina. A existência do pacote não activa menus, direitos, tabelas ou candidatas.
+
+Ver `sgf-tempo/docs/WORKSHOP_MATERIAL_CYCLE.md` e `docs/proofs/workshop-material-cycle-20261102.json` nesse repositório: repartição, devolução, histórico paginado, concorrência, recuperação e limitações. Código nativo de saída e escritor anterior preservados; não se repetiram suites nativas sem alteração. Sem dados/demos reais, publicação ou checkpoint.
diff --git a/docs/consultas-adjudicacao-sgf.md b/docs/consultas-adjudicacao-sgf.md
new file mode 100644
index 0000000..3b9428a
--- /dev/null
+++ b/docs/consultas-adjudicacao-sgf.md
@@ -0,0 +1,95 @@
+# Consultas, cotações e adjudicação — entrega para revisão
+
+02/11/2026. **Percurso implementado e confirmado em cópias isoladas; demonstração 8039 habilitada para preparação/avaliação/leitura, com adjudicação bloqueada pela composição de identidades autorizada.** O proprietário escolheu preparar sem criar quarta conta e autorizou o mapa exacto de novos direitos. Essa escolha foi respeitada; não se reduziu o mínimo de avaliadores nem se tornou Consulta num decisor.
+
+## Destino, estado e candidata
+
+| Elemento | Estado conferido |
+| --- | --- |
+| Demonstração existente | `http://127.0.0.1:8039`, pasta `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\development-app` |
+| Base/contexto | `sgf_modules_dev_2576427d1939`, porta **55439**, **tenant 1 / empresa 1 / unidade 1**, módulo Compras, opção `procurement.internal` |
+| Origem reutilizada | Requisição **ID 1, 100 MZN, aprovada, versão 3**, três eventos originais e **reserva única** preservados |
+| Dados preparados | Três fornecedores exclusivamente sintéticos, três cotações de 100/110/120 MZN e uma avaliação elegível por cotação; sem contactos reais |
+| Estado em 8039 | Comparação factual legível, classificação/seleccionabilidade incompletas: faltam segundo avaliador elegível e confirmador independente; **zero propostas de adjudicação e zero confirmações** |
+| Candidata final | `OPERATIONAL-CANDIDATE-040fb0cb67a77d61` |
+| ZIP privado | [040fb0cb67a77d61.zip](../storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-040fb0cb67a77d61.zip) |
+| SHA-256 ZIP | `dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a` |
+| SHA-256 manifesto | `040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58` |
+| Implantação final de código na demonstração | `040fb0cb67a77d618011bc7a`; integra a correcção de leitura da candidata 166e397e3ca63f02 |
+| SGF real | **0.1.1**, sem instalação, configuração, acessos ou base alterados; não existe implantação 8041 |
+
+O ecrã é acessível pelo detalhe da requisição aprovada e directamente em `/purchasing/sourcing/1`, depois de entrar com uma das contas anteriores e seleccionar Compras no contexto autorizado. Os campos de data/local de entrega da proposta ficam por preencher: são requisitos da adjudicação existente de Compras, não foram presumidos para a empresa e não foi criada proposta na demonstração. Valores logísticos de testes aparecem apenas nas cópias sintéticas e em pedidos recusados, nunca numa decisão real.
+
+## Mapeamento e reutilização de Compras
+
+| Processo existente | Reutilização e adaptação |
+| --- | --- |
+| `QuotationController::store` e `QuotationPricing` | Cobertura de todas as linhas da origem, quantidade e imposto preservados, preços/condições/validade/prazo, desconto antes do imposto. O motor comum `Tempo\Purchasing\Economic\QuotationPricing` é utilizado directamente; não se criou outro motor monetário. |
+| `ComparisonPolicy` e `ProposalComparison` | A candidata transporta os defaults existentes: **3 fornecedores, 2 avaliadores**, pesos preço/qualidade/entrega/histórico/pagamento **40/20/15/15/10**. Não são uma nova política presumida para a Tempo. O processo guarda a política aplicada; avaliações de versões antigas ou de intervenientes cuja elegibilidade foi revogada não qualificam a comparação actual. |
+| `ProposalScoring` | A mesma classe partilhada calcula pontuações e melhores propostas. Sem IA, rede ou consumo de serviços externos. |
+| `PurchaseWorkflow::select/canDecide/decide` | Preservadas origem aprovada, selecção entre melhores propostas, justificação, data/local previstos, exclusão do requerente e confirmação por outra identidade. Proposta guarda comparação completa, fingerprint e versão. Nesta entrega só está integrado o primeiro nível; acima desse limite é sinalizada cadeia pendente. |
+| `BudgetWorkflow` / `BudgetControl` e reserva da requisição | Usada a origem financeira já mapeada, dotação aprovada e a base com/sem imposto existente. Nenhuma chamada nova a reserve/award/commit/consume/release. Valor divergente é sinalizado para adaptação orçamental ainda não integrada; não se inventa reforço ou libertação. |
+| Autorizações/transacções SGF | Reutilizados sessão, CSRF, `CompanyContext`, `Permissions`, `RuntimeContext` e `Port::atomic`: empresa/utilizador/origem bloqueados, contexto e direitos revalidados também em repetições. |
+
+O novo núcleo é `packages/purchasing/src/Sourcing.php`. O adaptador, rotas e ecrã estão em `integrations/sgf/sourcing`. Os serviços/modelos activos de Compras autónomo não foram alterados por este incremento; a incorporação não transporta a base de Compras. O novo catálogo sintético é uma fonte local autorizada de selecção, não uma importação/reconciliação de fornecedores por igualdade de nome. Cadastro completo, revisão documental, conflitos/admissões/recomendações formais e ligação a cadastros reais continuam fora da cobertura declarada deste recorte sintético.
+
+## Acessos efectivamente acrescentados
+
+Somente após a resposta explícita do proprietário, em 1/1/1:
+
+| Conta existente | Acréscimos |
+| --- | --- |
+| Preparador ID 1 | `purchasing_sourcing.prepare` |
+| Revisor ID 2 | `purchasing_sourcing.evaluate`, `purchasing_sourcing.propose` |
+| Consulta ID 3 | **Nenhum**; conserva a leitura anteriormente autorizada |
+
+Nenhuma conta persistente foi criada. `purchasing_sourcing.confirm` foi acrescentada ao catálogo, mas **não concedida a qualquer conta da demonstração**. A quarta identidade sugerida foi recusada pelo proprietário e não foi criada. A confirmação usa ainda a atribuição ao departamento da origem; não basta acrescentar uma permissão isolada.
+
+A outorga inicial específica foi auditada com a identidade administradora existente e motivo da confirmação do proprietário; não houve herança automática de perfis ou mudança de direitos anteriores. A administração nativa continua a exigir que o gestor detenha a permissão que outorga/revoga. Como ID 1 não recebeu evaluate/propose, a gestão desses direitos pelo seu ecrã administrativo mantém essa restrição; não se concederam direitos operacionais adicionais para a contornar. A revogação nos testes foi exercitada por fixtures privilegiadas **somente nas cópias**, e a sua eficácia nos pedidos/repetições foi conferida no servidor.
+
+## Schema e actualizações
+
+Migração aditiva própria **sourcing revisão 1**:
+
+- Quatro tabelas: `ss_suppliers`, `ss_processes`, `ss_events`, `ss_commands`.
+- FKs de empresa/unidade/origem, unicidade por contexto/requisição e contexto/actor/chave; eventos/recibos imutáveis; origem e decisão final protegidas; versões avançam uma vez.
+- CHECK do catálogo de `permission_grants` ampliado com os quatro nomes unitários. Isso não concede acessos.
+- Nova entrada imutável `sourcing` em `sgc_installations`; a entrada original `internal-requisitions` revisão 2 não é reescrita. A guarda orçamental aceita a extensão apenas com assinatura e hash conhecidos e válidos.
+
+O pacote inclui `scripts/sgf-sourcing-install.php`: `plan` só lê; `migrate` exige destino, manifesto, backup/hash, operador e manutenção conferidos. A migração não cria perfis, fornecedores ou contas, não concede direitos e não habilita o módulo. Escreve recibo privado da transição exacta, que o actualizador exige ao mudar os drivers de schema. Receitas de outro host/manifesto ou ausentes não autorizam transição. O conteúdo sintético e as credenciais não fazem parte do ZIP.
+
+Não foram executados **GRANT/REVOKE SQL**, criadas roles ou promovida a ligação normal. A demonstração reutiliza a role proprietária que já tinha; essa limitação permanece explícita. O plano SQL do SGF real não foi aplicado, nem este incremento declara pronto o mapa SQL mínimo dos quatro novos objectos para uma instalação cliente restrita.
+
+## Resultados e provas
+
+| Prova | Resultado |
+| --- | --- |
+| [Núcleo](proofs/sgf-sourcing-core.json) | 12 controlos: mínimos, classificação comum, exclusão do requerente, proponente diferente do confirmador, cotação alterada/expirada, revogação, base exacta e fecho imutável. |
+| [Cópia isolada final](proofs/sgf-sourcing-isolated.json) | 6 grupos de instalação/migração, 11 de HTTP/concorrência e 8 de integridade/recuperação. Inclui falha de migração com rollback, replay, CLI empacotada, recibo exigido, dois processos concorrentes reais, versões conflituantes e confirmações concorrentes com só um resultado final. |
+| Falha depois de gravar o evento | Estado, evento e recibo revertidos na mesma transacção; nova tentativa prossegue sem duplicação. |
+| Versões e revogação | Alterar cotação invalida avaliações anteriores e impede confirmação de proposta obsoleta; revogação afecta sessões existentes e replay. |
+| Recuperação | Reversão de código preservou a adjudicação na base da cópia; estado pós-confirmação arquivado. Separadamente, restauro PostgreSQL do backup anterior reconciliou requisição/reserva/contas/direitos e ausência das tabelas/ledger da extensão. Base temporária eliminada depois dos testes. |
+| [8039 — HTTP](proofs/sgf-sourcing-demo-http.json) | Preparação/leitura das três cotações e avaliações; Consulta recusado em todas as mutações; falta do segundo avaliador sinalizada; valor diferente da reserva sinalizado; nenhum adjudicatário criado. |
+| [8039 — preservação](proofs/sgf-sourcing-demo.json) | Contas, requisição, reserva/consumo e direitos anteriores preservados; exactamente três acréscimos autorizados. |
+| [Relatório agregado](proofs/sgf-sourcing-delivery.json) | Candidata, hashes, ficheiros afectados, implantação final e estado parcial da demonstração. |
+
+A confirmação completa ocorreu **apenas em cópias descartáveis**, com identidade e atribuição departamental de fixture. Não se afirma confirmação completa em 8039. Não se executaram cenários destrutivos/concorrentes na demonstração persistente. A cópia foi adaptada à sua identidade física exacta sem desligar as guardas; dependências e autoloader foram copiados. Regressões integrais aceites de Compras/navegação/requisições não foram repetidas; as provas anteriores permanecem válidas e referenciadas. Revisão visual continua separada; nenhum navegador utilizado.
+
+## Recuperação concreta da demonstração
+
+Antes da primeira escrita, identidade física e contexto foram conferidos. Backup privado em `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\private\sourcing-recovery\before.dump`, SHA-256 **`cb9d55db5fee08cb36e3af4c962556b465be9e14071b5c3505c834d2ef4d7bcd`**, com índice conferido. `baseline.json` conserva hashes/contas/direitos anteriores; `applied.json` a primeira aplicação da extensão; `final-code.json` a aplicação final. Os journals de todas as versões/cópias de ficheiros e bundles anteriores estão em `development-app/storage/app/private/module-deployments`.
+
+1. Fechar só o servidor 8039, confirmar processo/pasta; nunca parar o cluster 55432 nem usar `sgf.ps1 stop`.
+2. Falha da migração antes do commit: rollback transaccional, conferir catálogo/tabelas/ledger antes de repetir; resultado incerto exige inspecção, não `down`.
+3. Falha de código: conservar manutenção e usar o ID/journal correcto da CLI de rollback. A actualização de refinamentos finais foi apenas de código, com hashes dos dados conferidos antes/depois. Não estreitar CHECK nem apagar ledger por arrasto.
+4. **Voltar ao bundle 166 depois da extensão de schema não reabre o percurso sozinho**: a sua guarda conhece apenas a assinatura anterior. Manter fechado até recuperar compatibilidade/schema; código e dados são recuperações distintas. Esse comportamento foi ensaiado nas cópias.
+5. Para dados/schema, preservar primeiro o estado actual, restaurar o backup numa base alternativa e reconciliar eventos/cotações/direitos posteriores ao corte antes de qualquer troca de ligação. Os resets usados nos testes atingiram só cópias descartáveis. Não restaurar por cima de 8039 ou do SGF real como simples operação de código.
+
+## Pendências para revisão
+
+- **Identidades:** mantendo três contas, requerente excluído e Consulta sem decisão, não existe segunda avaliação/confirmador elegível. A opção do proprietário foi preparar sem criar outra conta; não se pede nem presume nova autorização por continuidade.
+- **Orçamento:** resize da reserva, reforço, libertação, compromisso, consumo, rejeição/cancelamento com efeito financeiro não integrados neste incremento. Valores divergentes são sinalizados.
+- **Aprovação:** a cadeia acima do primeiro limite é bloqueada até integrar os níveis existentes; não é reduzida a uma confirmação simples.
+- **Configuração/cadastros:** defaults de comparação são compatibilidade da origem, preservados no processo; falta parametrização comercial desse recorte no SGF, cadastro/reconciliação real e procedimentos formais adicionais da origem. Não é entrega comercial integral.
+- **Administração/SQL:** delegação nativa restrita e owner/runtime da demonstração continuam como descrito; implantação cliente exige desenho/revisão próprios, sem reutilizar as credenciais/contas/dados sintéticos.
+- **Fora do incremento:** encomendas, recepção, pagamentos, contactos externos, contratação para terceiros, implantação real e revisão visual. Não foram introduzidos.
diff --git a/docs/decisao-instalacao-sgf.md b/docs/decisao-instalacao-sgf.md
new file mode 100644
index 0000000..643a58e
--- /dev/null
+++ b/docs/decisao-instalacao-sgf.md
@@ -0,0 +1,156 @@
+# Decisão de instalação da integração de requisições no SGF
+
+**Estado posterior de desenvolvimento:** candidata **040fb0cb67a77d61** acrescenta consultas/cotações/adjudicação ao módulo, com testes completos em cópias e preparação parcial em 8039. [Relatório e hashes actuais](consultas-adjudicacao-sgf.md). Existe extensão de schema própria; os planos anteriores de instalação real abaixo não se aplicam automaticamente à nova candidata. Não há autorização de instalação real, DCL, contas ou configuração Tempo. SGF real mantém **0.1.1**. A confirmação da adjudicação na demonstração permanece pendente por decisão de não criar quarta identidade, sem alterar mínimos ou independência.
+
+**Estado posterior — apenas demonstração 8039:** correcção de leitura incorporada na sucessora `OPERATIONAL-CANDIDATE-166e397e3ca63f02`, manifesto `166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de`, ZIP SHA-256 `620660d76329c800f3c6b18a521c03ccdadce09502d2a7744e1e91b77560f785`. [Entrega e matriz actualizadas](transferencia-sgf.md). Nenhuma instalação real autorizada ou executada: SGF real permanece 0.1.1. Planos/IDs do destino real e da candidata anterior abaixo são históricos e não autorizam aplicar esta sucessora; devem ser reconferidos se a implantação real voltar a ser solicitada.
+
+## Candidata corrigida — decisão actualizada em 02/11/2026
+
+**Preparação técnica concluída em isolamento; nenhuma instalação ou alteração de acessos autorizada no destino.** Esta secção sucede à proposta histórica abaixo. O SGF permanece em **0.1.1**. A recomendação passa a ser rever esta candidata para uma futura instalação técnica inicialmente desactivada; não há bloqueante de código conhecido no percurso básico ensaiado. A autorização da operação, o backup/restauro dos dados reais e a aprovação das alterações SQL continuam indispensáveis antes de executar.
+
+| Identificação | Valor conferido |
+| --- | --- |
+| Destino | `C:\laragon\www\sgf-tempo`; URL `http://127.0.0.1:8000` |
+| Base/ligação normal | `sgf_tempo_dev`, PostgreSQL `127.0.0.1:55432`, runtime `sgf_dev_app` |
+| Candidata corrigida | `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20` — identificação por conteúdo, não activação de 0.1.2 |
+| ZIP privado | `storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20.zip` na origem Compras |
+| SHA-256 ZIP | `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703` |
+| SHA-256 manifesto | `a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b` |
+| Plano de código, destino | `d29833b1c48b9d666522d20d87040d5ad30370cf114c9a70f272cd041eea59e9` |
+| Plano de schema, destino | `169e6e863f28b67740f07b6e7cbc664fbe585a7760a83dccebc8d482ad8ab792` |
+| ID de aplicação previsto | `a6478223e17a5430652f0fcb` — usar sempre o ID efectivamente devolvido |
+
+### Dependências, configuração e permissões
+
+A migração passa à **revisão 2, com 16 tabelas novas**: as 14 anteriores mais `cb_ui_commands` e `cb_consumption_sources`. Inclui constraints e histórico imutável. Mantém o alargamento e o CHECK do catálogo de `permission_grants`, preservando dados e direitos anteriores. Não executa a migração 46 de Compras. Tabelas preexistentes incompatíveis ou uma revisão 1 anterior exigem reconciliação explícita, não são sobrescritas. Neste destino não existe nenhuma dessas tabelas.
+
+O ecrã `/purchasing/internal-requisitions/budget-setup` permite criar centros e preparar/aprovar dotações com os serviços orçamentais existentes. Campos de valores/ano começam vazios; não existem centros, dotações, contas ou concessões sintéticas no pacote. Criar centro exige `purchasing_requisitions.configure` e `purchasing_budget.create`; dotações mantêm autorização e aprovação independente. Movimentos genéricos/consumo fora do percurso de requisição estão fechados nesta composição. O catálogo administrativo inclui os direitos e rótulos vindos do servidor, com frontend compilado no pacote; delegação indevida e consulta sem gestão são recusadas. Instalar não outorga direitos funcionais.
+
+**Ligação migradora:** usar `sgf_dev_owner` (ou principal DBA autorizado equivalente) exclusivamente no processo CLI migrador, com CONNECT à base, USAGE/CREATE no schema e autoridade de proprietário para alterar `permission_grants`, criar tabelas, constraints, sequências, funções e triggers. Não requer superutilizador, criação de bases/roles ou promoção do runtime. As credenciais devem ser fornecidas por canal privado ao processo, nunca no ZIP, argumentos, logs ou `.env` persistente. Se existir cache de configuração, invalidá-lo na janela aprovada e conferir `current_database()`/`current_user` antes do DDL; variáveis de ambiente não substituem silenciosamente um cache privilegiado. O runtime continua com a sua ligação habitual.
+
+O [plano SQL exacto proposto para sgf_dev_app](proofs/sgf-corrected-sql-plan.json) **não foi aplicado**. Contém GRANT e REVOKE apenas nos 16 novos objectos/sequências: SELECT em todos; INSERT nos cadastros/comandos/históricos necessários; UPDATE apenas em `cb_budgets`, `ir_requests`, `ir_steps`, `ir_policies`; USAGE apenas nas sequências com INSERT. Sem DELETE/TRUNCATE, UPDATE de históricos, ownership, CREATE ou alteração global de default privileges. Remove ACLs directas mais amplas herdadas dos defaults e de PostgreSQL `PUBLIC` nesses novos objectos; não altera direitos públicos funcionais nem as 87 tabelas existentes. Conferir também pertenças/inheritance e privilégios efectivos: outra role herdada pode exigir proposta adicional. **Qualquer execução efectiva desse plano, incluindo revogações, exige confirmação separada.** Não é executado pelo instalador.
+
+### Classificação actual das pendências
+
+| Elemento | Decisão actual |
+| --- | --- |
+| Duas tabelas em falta | **Resolvido na candidata**, incluídas na migração revisionada e no restauro verificado. |
+| Centros/dotações | **Implementado e ensaiado pela interface/API**. Valores reais e aprovação da empresa continuam pendentes para activação; não presumidos. |
+| Catálogo administrativo | **Implementado, compilado e ensaiado**, ainda não instalado nem com direitos atribuídos no destino. |
+| SQL migrador/runtime | **Mapa e separação ensaiados**. Confirmar operador/ligação e alterações SQL reais antes da instalação; não há concessão automática. |
+| Equipamentos/peças | **Fora do âmbito**, rejeitados no servidor. Não bloqueiam consumíveis, serviços e outros; não converter tipos para contornar essa exclusão. |
+| Navegação modular | Aceitação técnica anterior reutilizada; entrada, selecção de módulo e assets desta composição conferidos na reprodução. Revisão visual, associações antigas e volume continuam separados. |
+| Dois administradores/configuração real | Pendentes para entrega/activação. Não criar nem promover utilizadores automaticamente. |
+| Contratações para terceiros | Excluídas do SGF; nenhuma introduzida nesta candidata. |
+
+### Prova nova e limites
+
+[Prova da candidata corrigida](proofs/sgf-corrected-delivery.json): **157 controlos aprovados** (incluem conferência individual das ACLs das 87 tabelas anteriores), usando cópia do código actual e exportação **só de estrutura**, sem contas/dados reais importados. A migração produziu 103 tabelas na base descartável; owner e runtime eram roles distintas, restritas e sintéticas. Foram ensaiados falha transaccional de migração, instalação vazia, centros/dotações, três tipos básicos, reserva, identidade independente, histórico/consulta, revogação, contexto forjado, navegação, catálogo/compilação frontend, proibições DDL/DML, actualização e reversão de código. O restauro em segunda base reconciliou **103 impressões digitais de tabelas**, schema/triggers, origem, decisão e reserva, incluindo acesso pelo runtime sem DDL. Bases e roles de teste foram removidas.
+
+As provas anteriores de concorrência, adaptadores, caches e navegação modular permanecem válidas e não foram repetidas integralmente. Esta reprodução não é backup/restauro dos dados reais do SGF, nem aprovação visual integral ou entrega comercial de todos os módulos. O destino foi lido para schema/metadados e reconferido: permissões/pertenças/auditoria, estrutura e ponteiro preservados.
+
+### Procedimento exacto proposto — não executar sem confirmação
+
+1. Extrair o ZIP para pasta privada de staging no próprio destino; conferir os hashes acima, manifesto e os dois planos de leitura. Nenhuma instalação cliente depende da pasta Compras. Repetir planos imediatamente antes da janela: divergência exige reconciliação. Não executar comandos a partir de outro bundle antigo.
+2. Pelo procedimento DBA autorizado, guardar backup integral custom de **somente `sgf_tempo_dev`**, código/configuração, documentos externos e ACLs, com hash e corte registados, em pasta privada fora da raiz web. Ensaiar esse backup real numa base nova e conferir ownership, ACLs, constraints/triggers, contagens/digests e arranque restrito. `pg_restore --list` não basta. Não parar o cluster 55432 nem usar `sgf.ps1 stop`.
+3. Na janela aprovada, colocar apenas este projecto em manutenção e parar os seus escritores. Executar `plan` e `migrate` com a ligação migradora temporária. Aplicar **somente o plano SQL expressamente confirmado**, numa transacção própria; conferir privilégios efectivos e preservação das ACLs anteriores. Voltar ao runtime para as verificações normais.
+4. Aplicar código com o manifesto e plano de código reconferidos; registar o ID retornado. Recompilar caches nativos com o runtime, preservando a configuração operacional **desactivada**. Conferir arranque e inacessibilidade das operações desactivadas. Não alterar `packages/purchasing/current.json`, atribuir direitos, preencher valores ou activar âmbitos nesta operação.
+5. Comandos parametrizados (executar através do PHP fixado do SGF; `` é o staging extraído, `` o destino acima; restantes valores vêm dos planos/backup/operador, nunca são presumidos):
+
+```text
+php /scripts/sgf-internal-install.php plan
+php /scripts/sgf-internal-install.php privileges-plan sgf_dev_app
+php /scripts/sgf-internal-install.php migrate
+php /scripts/sgf-code-deploy.php plan
+php /scripts/sgf-code-deploy.php apply --offline
+php /scripts/sgf-code-deploy.php cache --offline
+```
+
+6. Falha de migração: rollback transaccional; se resultado do commit for incerto, inspeccionar ledger/assinatura antes de retry. Falha de aplicação/caches: manter manutenção, `php /scripts/sgf-code-deploy.php rollback --offline`, conferir hashes e reconstruir os caches da versão reposta quando aplicável. Na primeira instalação, regressar ao host sem bundle; não inventar versão anterior. A reversão de código preserva schema, dados e ACLs e não os recupera.
+7. Falha de dados/schema: preservar estado falhado; restaurar o backup para **outra base**, reconciliar operações posteriores ao corte, revogações e documentos, conferir com runtime restrito e decidir separadamente a troca de ligação/caches. Não executar `down`, apagar tabelas ou restaurar por cima da base activa. Mudança de base exige novo fingerprint operacional. Direitos SQL não são revertidos indiscriminadamente com o código.
+
+**Próxima decisão do proprietário:** confirmar ou recusar uma operação concreta de instalação técnica desactivada, incluindo ligação migradora e plano SQL. Não foi pedida nem inferida essa confirmação nesta preparação. Activação funcional e configuração real ficam para decisão própria.
+
+---
+
+## Registo histórico anterior — sucedido pela candidata corrigida acima
+
+Data: **02/11/2026**. Estado: **proposta para decisão; instalação não autorizada nem executada**.
+
+## Recomendação concreta
+
+**Não confirmar ainda a instalação desta candidata como percurso operacional.** Os mecanismos e provas isoladas permanecem válidos, mas a conferência do destino revelou lacunas de implantação que esses ensaios com roles proprietárias e cadastros sintéticos não cobriam: ligação de migração/privilégios SQL da aplicação e preparação operacional de centros/dotações pela interface.
+
+A operação futura proposta é **instalação técnica de schema e código, inicialmente desactivada**, no destino abaixo, sem outorga automática de direitos, sem dados de Compras e sem mudar o pacote activo 0.1.1. Antes de pedir a confirmação dessa operação, fechar o plano de privilégios e de recuperação do destino. A disponibilização aos utilizadores exige ainda resolver o cadastro/orçamento e a configuração nominativa. Não há autorização para executar `migrate`, `apply`, `cache`, concessões, manutenção ou paragem/reinício de serviços nesta decisão.
+
+## Destino conferido, não uma instalação genérica
+
+| Elemento | Valor |
+| --- | --- |
+| Projecto SGF local | `C:\laragon\www\sgf-tempo` |
+| URL configurada | `http://127.0.0.1:8000` — não é publicação num domínio |
+| Ambiente/PHP | `local`; PHP **8.4.27** pelo wrapper do SGF |
+| Base efectiva | PostgreSQL **`sgf_tempo_dev`**, `127.0.0.1:55432` |
+| Role da aplicação | `sgf_dev_app` — sem CREATE no schema public e sem autoridade para ALTER em permission_grants |
+| Proprietário existente da tabela de permissões | `sgf_dev_owner` — LOGIN e CREATE em public confirmados; credenciais não consultadas nem registadas |
+| Estado anterior | 87 tabelas public; fundação de identidade presente; nenhum `cb_*`, `ir_*` ou `sgc_installations` da candidata |
+| Configuração de integração | Bundle/ficheiro operacional ausentes; manutenção inactiva; configuração Laravel não compilada no instante da leitura |
+| Pacote instalado de Compras | **0.1.1**, ponteiro SHA-256 `81899e531e9eec449717e694d8bdc106a9e220a828f5491ce918c1d2dc612787` |
+| Revisão Git SGF observada | `fcf0ada22701337acd4c84b404207b8400f490a2`; não substitui a conferência dos ficheiros locais em curso |
+
+Inventário por **transacção PostgreSQL READ ONLY**, sem migrar: [prova da decisão](proofs/sgf-installation-decision-inventory.json). As contagens/impressões digitais preservadas incluem 89 concessões, duas pertenças e 162 eventos de auditoria. Há uma empresa, uma unidade e **uma concessão de administração da empresa**; não foram enumerados nomes, emails ou credenciais. A base `sgf_tempo_test`, a instância modular isolada em 8039/cluster 55439 e as demonstrações anteriores não são este destino e não podem ser reiniciadas/resetadas por arrasto.
+
+O registo antigo de processo web não foi tomado como confirmação de processo actualmente activo. Na janela futura, conferir o PID/executável/instante de arranque e vinculação ao projecto antes de actuar. **Não usar `sgf.ps1 stop` indiscriminadamente**: esse comando também pára o cluster PostgreSQL 55432, partilhado com outras bases.
+
+## Candidata exacta e planos de leitura
+
+| Referência | Valor |
+| --- | --- |
+| Identidade da candidata | **`OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba`** — versão identificada por conteúdo, não 0.1.2 |
+| Directório | `C:\laragon\www\compras\storage\app\private\candidates\OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba` |
+| ZIP | `C:\laragon\www\compras\storage\app\private\candidate-archives\OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba.zip` |
+| SHA-256 ZIP, reconferido | `d14a3261d5aaab80f96250af781c700ab53376021f4638e3aee43c15ff37ceed` |
+| SHA-256 manifesto | `b1ec47f7a9ad87e0b232bac41e402d4a00ee5f03f00a79b93effc692ce8b39a2` |
+| Impressão de destino do plano de código | `bab719ee4e08555a8e1de694dd3a93df82680c7aba653613f0714dd76e4065d6` |
+| Impressão de destino do plano de schema | `169e6e863f28b67740f07b6e7cbc664fbe585a7760a83dccebc8d482ad8ab792` |
+| ID previsto de primeira aplicação | `b1ec47f7a9ad87e0b232bac4` — reconfirmar no retorno real da CLI |
+
+Os dois comandos `plan` passaram no destino e só leram/conferiram estado. Isso **não executa nem valida a capacidade de escrever DDL com a role runtime**, e `schema_prepared=false`. Os hashes são uma fotografia: repetir os planos de leitura imediatamente antes de uma operação aprovada; divergência impede usar esta decisão antiga como autorização para sobrescrever o destino.
+
+## Alterações previstas, sem efeitos já aplicados
+
+**Código:** criar o bundle local `packages/internal-requisitions`; aplicar sobreposições conferidas apenas a `Permissions.php`, `IdentityWorkflow.php`, `BudgetAdapter.php`, `AppServiceProvider.php` e `ModuleNavigation.php`. Guardar backups/journal em `storage/app/private/module-deployments`; tratar os caches padrão. Preservar restantes ficheiros/trabalho local. Não alterar `packages/purchasing/current.json`, activar 0.1.2, publicar ou copiar a base de Compras.
+
+**Schema, revisão 1:** acrescentar **14 tabelas**: sete `cb_*` (`centres`, `origins`, `budgets`, `budget_entries`, `budget_consumptions`, `budget_reinforcements`, `audit`), seis `ir_*` (`requests`, `steps`, `events`, `commands`, `policies`, `policy_versions`) e `sgc_installations`. Sem dados sintéticos iniciais. Alargar `permission_grants.permission` de 40 para 100 e estender o CHECK unitário com os novos valores, preservando a expressão e os registos anteriores. Acrescentar FKs, unicidade e triggers de origem/versão/aprovação independente/histórico imutável. Os dados e decisões existentes não são classificados nem reescritos. **A migração 46 de Compras não é aplicada no SGF.**
+
+**Permissões funcionais:** o catálogo candidato acrescenta seis direitos de requisição (`view`, `prepare`, `configure`, `approve.department_head`, `approve.procurement`, `approve.finance`) e os sete direitos do orçamento comum já desenvolvido. A criação de unidades continua a conceder apenas `unit.view`/`unit.edit`. Instalar o catálogo **não concede** nenhum novo direito a administradores, perfis de desenvolvimento ou outros utilizadores. A atribuição inicial nominativa e auditada exige âmbito habilitado explicitamente; as seguintes usam a administração nativa. Requerente e decisores continuam a exigir identidades distintas. A concessão de `purchasing_budget.record` é necessária para reserva/libertação, sem substituir a autorização de requisição.
+
+**Privilégios PostgreSQL:** são outra camada. `sgf_dev_app` não deve receber CREATE, ownership ou a palavra-passe do proprietário. A execução de DDL exige a ligação de migração autorizada (`sgf_dev_owner` ou principal autorizado equivalente), só nessa execução e sem reescrever `.env` com credenciais privilegiadas. Depois, a aplicação precisa dos privilégios mínimos nas novas tabelas/sequências. **O instalador desta candidata não entrega esses GRANTs à role runtime**; as provas anteriores usaram role proprietária nas bases descartáveis. Falta fechar/ensaiar esse mapa com as duas roles separadas. O plano deve permitir leitura do registo de instalação, operações de requisição/política, append-only do histórico, reserva/auditoria e locks orçamentais necessários, sem UPDATE/DELETE sobre históricos ou grants genéricos sobre todo o schema. Nenhum GRANT é executado nesta análise.
+
+## Bloqueantes e elementos fora do âmbito
+
+| Questão | Classificação e impacto |
+| --- | --- |
+| Ligação de migração e privilégios SQL runtime | **Bloqueante para executar a instalação tal como está com a ligação normal.** A ligação actual não pode executar o DDL. Definir execução migradora e mapa mínimo de acesso a tabelas/sequências, preservar o runtime restrito e ensaiar a separação antes de aplicação. |
+| Centros e dotações de orçamento em instalação vazia | **Bloqueante para disponibilizar o percurso básico neste destino.** Não existem `cb_*`; o formulário candidato só selecciona centros existentes e a configuração exige pelo menos um. Não há no percurso entregue cadastro/associação inicial de centros pela interface. Os ensaios prepararam essas fontes e a dotação aprovada por fixtures; não importar fixtures nem criar SQL manual como configuração comercial. |
+| Interface orçamental SGF existente | **Bloqueante para a preparação operacional pela interface sem correcção.** `BudgetWorkspace` consulta `cb_ui_commands` e `cb_consumption_sources`, ausentes no destino e no schema de 14 tabelas. A reserva pelo motor comum foi ensaiada; isso não declara entregue o bootstrap administrativo/orçamental completo. Não ampliar consumo/facturas por arrasto: integrar o recorte necessário ou manter esse percurso fechado até completar a sua composição. |
+| Catálogo administrativo de permissões | **Implementado e testado na candidata; ainda não instalado.** No destino, `Permissions::UNIT` continua só com dois direitos. Aplicar a sobreposição resolve a presença/gestão dos novos nomes; não resolve sozinho DCL PostgreSQL, centros/dotações ou atribuições nominativas. Não classificar como falta de implementação do catálogo nem como acessos já configurados. |
+| Duas administrações nominativas | **Pendente para entrega/activação**, não condição para copiar código desactivado. O destino tem uma concessão `access.manage`; a regra da entrega exige duas administrações permanentes da empresa. Não criar conta/perfil ou outorgar direitos automaticamente para satisfazer a contagem. |
+| Âmbito/configuração e dotação reais | **Pendente para activação:** seleccionar empresa/unidade, centros/fontes, intervenientes, política/limites, responsáveis e dotação aprovada pelas regras usuais. Não retirar a exigência de orçamento para contornar a falta de preparação. |
+| Equipamentos/peças | **Fora do âmbito deste percurso básico.** Não impedem consumíveis, serviços e outros de completar requisição/submissão/aprovação/consulta quando as dependências acima estiverem preparadas. Equipamentos/peças não têm o seu percurso técnico SGF entregue; não os registar como `other` para contornar isso. |
+| Navegação modular | **Tecnicamente aceite em 02/11/2026** na entrega própria. Não é bloqueante técnico da candidata. Revisão visual/recargas, associações antigas e volume/paginação mantêm o âmbito separado dessa aceitação. Não repetir as suites para resolver a instalação. |
+
+## Backup, restauro e recuperação — procedimento proposto, não executado
+
+1. **Antes de qualquer escrita aprovada:** reconferir raiz, URL/base/roles, hashes e estado dos ficheiros/contas/auditoria. Guardar o código/configuração anterior e documentos/ficheiros externos. Reservar directório privado por execução em `C:\laragon\data\sgf-tempo\backups\purchasing\`, fora da raiz pública e sem sobrescrever backups anteriores. O backup de configuração pode conter segredos; não o distribuir com o ZIP.
+2. **Backup integral apenas de `sgf_tempo_dev`:** PostgreSQL 17 `pg_dump --format=custom` com host `127.0.0.1`, porta `55432`, ligação autorizada e ficheiro privado. Registar hash, instante de corte, código/manifesto/schema, inventário das 87 tabelas e ACLs/roles pertinentes. Credenciais por mecanismo privado como PGPASSFILE; nunca nos argumentos/logs. Não parar o cluster inteiro nem exportar/substituir outras bases.
+3. **Restauro para base nova vazia:** criar `sgf_tempo_dev_restore_` pelo procedimento DBA autorizado, restaurar o dump e conferir dados, documentos externos, tabelas, constraints/triggers, ACLs, contagens/digests de acessos/pertenças/auditoria e arranque com roles distintas. Os ensaios anteriores provaram restauro isolado, mas **não substituem o ensaio desta base e dos seus privilégios**. `pg_restore --list` só lê o índice. Se usar `--no-owner --no-privileges`, repor/validar explicitamente as ownerships/ACLs, incluindo CONNECT/USAGE, antes de ligar o runtime. Não converter o runtime em proprietário para fazer o ensaio passar.
+4. **Janela de aplicação, só depois dos bloqueantes e da confirmação:** manutenção, parar apenas serviços de escrita desse projecto; preparar schema pela ligação migradora; aplicar o mapa SQL aprovado; conferir preservação. Aplicar código e compilar caches pelas CLIs aprovadas, com destino/manifesto reconferidos. Não outorgar direitos funcionais, habilitar âmbitos ou abrir o módulo por arrasto. Reiniciar/conferir serviços próprios mantendo o acesso fechado até decisão de activação.
+5. **Falha de schema antes do commit:** rollback transaccional e conferência com o inventário anterior. Se a ligação se perder durante o commit, inspeccionar revisão/schema antes de repetir. Não executar migrações `down` ou apagar histórico para forçar retry.
+6. **Falha de código/aplicação parcial:** manter serviços parados/manutenção; `sgf-code-deploy.php rollback` para o ID devolvido/registado; conferir backups/hashes. A rotina repõe ficheiros/bundle anterior, recusa alterações locais e não reverte dados/schema/concessões. Depois de cache nativo recompilado, invalida-o; executar `cache` para a versão reposta, quando houver bundle anterior, e reiniciar/conferir antes de reabrir. Na primeira instalação, voltar ao host sem bundle não cria uma versão anterior de requisições. O ponteiro 0.1.1 continua preservado.
+7. **Corrupção ou incompatibilidade de dados/schema:** preservar também o estado que falhou; restaurar para outra base, nunca por cima da base activa. Reconciliar operações posteriores ao backup, configurações e revogações para não perder decisões ou reabrir direitos. Só trocar a ligação da aplicação após conferência e decisão registada; uma base restaurada com outro nome requer nova impressão de destino operacional e caches recompilados. Conservar a base anterior para diagnóstico. Rollback de código não é restauro de dados.
+
+Os comandos parametrizados e contratos das CLIs estão em [transferencia-sgf.md](transferencia-sgf.md). **Não pedir confirmação para executar um procedimento ainda incompleto:** primeiro resolver os bloqueantes identificados, actualizar/ensaiar a candidata e apresentar os novos hashes se houver alteração. O ZIP actual permanece imutável e disponível como evidência, não aprovado para implantação operacional.
+
+
+Actualiza??o de 02/11/2026: a proposta desactivada anterior foi substitu?da pela candidata utiliz?vel 401ce31ae5f2dd20, ainda N?O instalada. Fonte vinculativa actual: C:/laragon/www/sgf-tempo/docs/PURCHASING_INSTALLATION_PROPOSAL.md. Mant?m-se 0.1.1, aus?ncia de concess?es e decis?o posterior de instala??o/configura??o.
diff --git a/docs/demonstracao-requisicoes-8039.md b/docs/demonstracao-requisicoes-8039.md
new file mode 100644
index 0000000..e9c663a
--- /dev/null
+++ b/docs/demonstracao-requisicoes-8039.md
@@ -0,0 +1,49 @@
+# Requisições internas na demonstração 8039 — execução autorizada
+
+**Extensão posterior:** [consultas/cotações/adjudicação](consultas-adjudicacao-sgf.md), candidata 040fb0cb67a77d61, preserva este percurso e a origem de 100 MZN. Preparação/leitura do novo percurso estão disponíveis em 8039; adjudicação completa foi testada apenas nas cópias, por faltar composição de identidades autorizada na demonstração. O relato abaixo preserva a entrega anterior.
+
+## Actualização de leitura — limitação resolvida em 02/11/2026
+
+A autorização posterior do proprietário permitiu corrigir exclusivamente a leitura no adaptador, sem direitos novos. A sucessora `OPERATIONAL-CANDIDATE-166e397e3ca63f02` está aplicada em 8039; Consulta lê a requisição existente ID 1/100 MZN na listagem, detalhe e histórico, mantendo todas as recusas de escrita/aprovação. Foi ensaiada a reversão para a anterior e a reaplicação; 109 tabelas reconciliadas, uma reserva, versão 3, contas/direitos/configuração preservados e auditoria apenas acrescentada. [Matriz, hashes e provas actuais](transferencia-sgf.md). O relato da limitação abaixo é histórico, sucedido por esta correcção. Revisão visual e implantação real continuam separadas.
+
+02/11/2026. A confirmação do proprietário foi executada **apenas** na demonstração existente. Não foi criada instalação 8041, base ou conta nova, nem executado GRANT/REVOKE SQL. A instalação real permanece adiada, com `sgf_tempo_dev`, configuração Tempo e pacote 0.1.1 preservados.
+
+## Local e configuração aplicada
+
+- URL: `http://127.0.0.1:8039`; seleccionar a empresa/unidade sintéticas e o módulo Compras.
+- Pasta: `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\development-app`.
+- Base `sgf_modules_dev_2576427d1939`, cluster `sgf_modular_regression_2576427d1939`, porta 55439; contexto `1/1/1`.
+- Empresa Horizonte — Demonstração / Unidade Central — Demonstração; três contas sintéticas anteriores, mesmas credenciais e mesmos registos de utilizador.
+- Centro ID 1 `SINTETICO-REPRO`, referência de política `OPER`; dotação ID 1: **10.000,00 MZN sintéticos**, exercício 2026, operacional, inclui impostos; preparada por ID 1 e aprovada por ID 2.
+- Política activa com orçamento obrigatório, limites de 10.000,00 e 20.000,00, impostos incluídos no cálculo; intervenientes Preparador e Revisor em OPER, conforme proposta aprovada. O montante disponível do exemplo só cobre o primeiro nível.
+- Candidata `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`, manifesto `a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b`; schema revisão 2 e código aplicado sem variante de compatibilidade. O ponteiro purchasing 0.1.1 continua preservado, inclusive na cópia.
+
+Foram acrescentados exactamente os **14 direitos aprovados**, 8 ao utilizador 1, 4 ao 2 e 2 ao 3, apenas no contexto 1/1/1. [Prova com lista exacta e comparação dos direitos/contas anteriores](proofs/sgf-demo-8039-delivery.json). A inicialização extraordinária foi fechada após a outorga; administração posterior segue o catálogo nativo. Nenhum direito anterior foi retirado ou reescrito.
+
+O servidor foi parado/reiniciado exclusivamente em 8039. A sessão local estava configurada como `array`, que perdia o estado entre pedidos depois desse arranque; passou a `file` no `.env` desta cópia, preservando autenticação e CSRF, sem alterar contas ou palavras-passe. Não houve compilação de caches nem mudança no `.env` real. A ligação proprietária isolada é limitação conhecida e não modelo de instalação cliente.
+
+## Verificação e limitação encontrada
+
+[Verificação HTTP focada](proofs/sgf-demo-8039-http.json) e [reconciliação final SQL somente de leitura](proofs/sgf-demo-8039-delivery.json):
+
+- Login das três contas existentes; configuração/grants pelos mecanismos auditados existentes.
+- Centro e dotação preparados pela API, com aprovação de dotação por outra identidade; autoaprovação recusada.
+- Requisição ID 1 de **100,00 sintéticos**, submetida por ID 1, aprovada por ID 2; consulta de origem/versões e três eventos pelo Preparador e Revisor.
+- Repetição da submissão com a mesma chave devolveu a mesma requisição; reconciliação confirmou **uma requisição e uma reserva**, sem duplicação.
+- Perfil Consulta recusado em criação de centro, preparação, configuração de política, submissão, decisão, aprovação de dotação e administração de acessos. Nenhuma mutação indevida criada.
+
+**Limitação funcional explicitamente detectada:** `purchasing_requisitions.view` sozinho não autoriza uma terceira identidade a ler este registo; a regra actual restringe a consulta a requerente/configurador/decisores elegíveis. A Consulta abre a listagem mas recebe 404 no registo da outra pessoa. Não se concedeu aprovação/configuração para contornar isso nem se alterou a regra de visibilidade. A proposta de um perfil que consulte requisições de terceiros não fica demonstrada como concluída: requer decisão específica sobre visibilidade e eventual correcção do servidor. Esta limitação não impede o percurso Preparador → Revisor → consulta pelos intervenientes já autorizados.
+
+As recusas ao Consulta podem ocorrer antes da autorização de operação, pela regra de visibilidade (404), e não apenas por 403. As provas anteriores de concorrência/restauro/caches e navegação não foram repetidas integralmente. Revisão visual permanece separada; nenhum navegador utilizado.
+
+## Recuperação preparada antes da primeira escrita
+
+Pasta privada existente: `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\private\requisitions-recovery`.
+
+- `before.dump`: backup custom integral **só da base sintética**, SHA-256 `2eed6d9f775d8d5dc8c7b1606e230b8782c09a69098d31e659d9ad28a4d608fa`; índice conferido por pg_restore. Não se declara novo ensaio integral de restauro deste dump; as provas anteriores de recuperação são reutilizadas.
+- `files/`: cópias anteriores de todos os overlays, `.env`, configuração modular e ponteiro; `baseline.json`: identidade, grants anteriores e hash das contas; `applied.json`: ID `a6478223e17a5430652f0fcb` e journal de aplicação.
+- Journal/backups nativos: `development-app/storage/app/private/module-deployments`. Preservar também os três novos ficheiros `config/purchasing-{operational,internal,access}.php` ao diagnosticar uma falha; não existiam antes.
+
+Em falha, fechar apenas 8039. Reversão de código usa a CLI do bundle e o ID registado, sob manutenção/servidor parado; repor os ficheiros de configuração anteriores, incluindo `.env`, e retirar da composição os novos ficheiros apenas após os arquivar. Não voltar a criar contas ou apagar concessões para simular reversão. Código não reverte dados: recuperar o dump para base alternativa com procedimento DBA autorizado, reconciliar quaisquer operações posteriores ao backup e só então decidir a ligação da demonstração. Não sobrescrever a base activa, não usar comandos de paragem do cluster partilhado e não tocar na porta 55432.
+
+Scripts específicos deste incremento: `scripts/sgf-demo-8039.php` e `scripts/sgf-demo-8039-http.mjs`. A preparação recusa sobrescrever a recuperação anterior. O runner HTTP é um registo operacional com chaves persistentes privadas; não o reaplicar cegamente após o fecho da inicialização. Não contém credenciais nem acede a contas reais.
diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md
index 51ed5bf..ddafdfd 100644
--- a/docs/desenvolvimento.md
+++ b/docs/desenvolvimento.md
@@ -1,5 +1,57 @@
# Desenvolvimento do SGC
+## Consulta de fornecedores/cotações/adjudicação — entrega para revisão, 02/11/2026
+
+Implementado núcleo partilhado Sourcing e adaptador/ecrãs SGF, reutilizando preços/descontos e pontuação existentes, defaults preservados de comparação, autenticação/contexto/permissões e reserva da origem. Candidata **040fb0cb67a77d61**, ZIP SHA-256 `dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a`; [relatório/matriz/provas](consultas-adjudicacao-sgf.md). Fonte aprovada ID 1/100 MZN/versão 3, reserva e consumo preservados; sem nova requisição ou efeito de pagamento. Schema aditivo com quatro tabelas e ledger sourcing R1; ledger base R2 preservado, catálogo explicitamente ampliado sem outorga automática.
+
+Após a decisão do proprietário «Preparar sem criar outra conta» e autorização do mapa, concedidos **somente três direitos** em 1/1/1: prepare ao ID 1, evaluate/propose ao ID 2. Consulta sem acréscimos. Em 8039 ficam três fornecedores/cotações e uma avaliação elegível por cotação, **sem proposta/confirmação** por faltar o segundo avaliador e confirmador independente. Fluxo completo passou apenas nas cópias, com identidade/departamento de fixture; nenhum utilizador persistente criado. Testados replay, concorrência real/CAS/confirmações concorrentes, versão obsoleta, revogação, acesso indevido, falha transaccional, migração/replay/CLI, imutabilidade e recuperação de código separada de restauro de dados/schema. Refinamentos finais de código preservaram também os dados SS antes/depois. Provas anteriores aceites reutilizadas.
+
+Pendências explícitas: composição de identidades, parametrização comercial/cadastro real, orçamento divergente, níveis acima do primeiro limite e restrição nativa de delegação. Não ampliar direitos ou mudar regras por arrasto. Próxima decisão é a revisão destas limitações, não iniciar encomendas/recepção/pagamentos. Tempo/sgf_tempo_dev/0.1.1 preservados; nenhuma publicação, contacto externo, conta 8041 ou GRANT/REVOKE SQL. Revisão visual separada.
+
+## Primeiro percurso de requisições fechado em 8039 — 02/11/2026
+
+Autorizada e aplicada a correcção de leitura exclusivamente no adaptador SGF: view no contexto permitido já não exige autoria/aprovação/configuração. Nova candidata `166e397e3ca63f02` altera apenas Port.php face à 401ce31ae5f2dd20; hashes e [provas de HTTP/actualização/reversão/reconciliação](transferencia-sgf.md) registados. Consulta lê listagem/detalhe/três eventos da requisição existente de 100 MZN, sem recriação/submissão; recusas de escrita, decisão, administração e contexto indevido mantidas. Reversão reproduziu a limitação antiga e reaplicação restituiu leitura; reserva única, versão 3, direitos/contas/configuração intactos. 109 tabelas reconciliadas na demonstração actual; auditoria anterior preservada com novos eventos de recusas. Sem schema/DCL ou implantação real. Próximo percurso escolhido: requisição aprovada → processo/consulta de fornecedores → cotações/comparação → adjudicação independente e histórico, ainda por incorporar. Não confundir com integração integral. Revisão visual separada; Tempo/sgf_tempo_dev em 0.1.1 preservados.
+
+## Execução autorizada na demonstração 8039 — 02/11/2026
+
+Confirmados pasta/base/cluster/contexto e preparado backup privado antes da escrita. Habilitada candidata somente no desenvolvimento existente 8039/55439, com exactamente 14 direitos aprovados no contexto 1/1/1, centro SINTETICO-REPRO/OPER e dotação 10.000 MZN/2026 aprovada por ID 2 após preparação por ID 1. Requisição 1/100,00 submetida, retry sem reserva duplicada, aprovada por outra identidade e consultada pelos intervenientes. Contas/direitos anteriores preservados; bootstrap extraordinário fechado. Sessões desta cópia corrigidas de array para file. [Entrega/provas/recuperação e limitação de consulta](demonstracao-requisicoes-8039.md): view isolado não permite ao Consulta ler requisição alheia, recebe 404; não alterada a regra nem acrescentados direitos para contornar. Próxima decisão é definir esse âmbito de leitura. Nenhum GRANT/REVOKE, 8041, conta nova, publicação ou alteração da Tempo/sgf_tempo_dev; implantação real adiada, 0.1.1. Revisão visual separada e provas anteriores reutilizadas.
+
+## Proposta única de desenvolvimento isolado — próximo âmbito corrigido
+
+Por indicação do proprietário, suspensa a proposta de instalação técnica desactivada no SGF real. [Proposta concreta](proposta-desenvolvimento-sgf.md) reutiliza desenvolvimento persistente 8039/55439, base `sgf_modules_dev_2576427d1939`, empresa/unidade e três identidades sintéticas existentes. Leitura READ ONLY confirmou a identidade e role proprietária e ausência de cb/ir; nenhum acesso alterado. Valores de centro/dotação/política são exclusivamente proposta sintética. Sem novo DCL, sem segundo administrador exigido para demonstração; regra de dois na entrega empresarial identificada. Confirmação pendente antes de habilitar/atribuir acessos; `sgf_tempo_dev` e Tempo intactos, pacote 0.1.1.
+
+## Candidata SGF corrigida — estado actual, 02/11/2026
+
+Sucede ao inventário/bloqueantes históricos abaixo: `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`, schema revisão 2 com as duas dependências incluídas, centros/dotações por interface e catálogo administrativo compilado. **157 controlos aprovados**, restauro de **103 tabelas**, roles distintas e restritas, apenas dados sintéticos em bases removidas. [Decisão/procedimento actualizado](decisao-instalacao-sgf.md), [matriz](transferencia-sgf.md), [prova](proofs/sgf-corrected-delivery.json) e [SQL proposto, não executado](proofs/sgf-corrected-sql-plan.json). SGF permanece **0.1.1**, sem instalação, activação ou alteração de acessos/base. Próximo passo exige confirmação separada da operação concreta e SQL, backup/restauro real e configuração da empresa; detalhes do incremento no registo final deste documento.
+
+## Decisão de instalação: inventário real e bloqueantes — 02/11/2026
+
+Pedido expresso: apresentar decisão concreta, **sem instalar até confirmação**. Reconferidos ZIP/hashes e planos read-only; inspeccionada a base efectiva `sgf_tempo_dev`/127.0.0.1:55432 por transacção READ ONLY. Resultado em [proofs/sgf-installation-decision-inventory.json](proofs/sgf-installation-decision-inventory.json); decisão em [decisao-instalacao-sgf.md](decisao-instalacao-sgf.md). 87 tabelas existentes, nenhuma cb/ir/instalação, role runtime `sgf_dev_app` sem DDL; proprietário `sgf_dev_owner` identificado, sem credenciais consultadas. Baseline de concessões/pertenças/auditoria e ponteiro preservado. Sem backups, migrações, grants, aplicação de código ou controlo de serviços.
+
+Recomendação fundamentada: não aprovar o ZIP actual como percurso operacional no destino. Além de ligação migradora e privilégios mínimos das novas tabelas/sequências (não incluídos no instalador para runtime separado), falta bootstrap operacional por interface dos centros/dotações; `BudgetWorkspace` depende de cb_ui_commands/cb_consumption_sources ausentes do contrato candidato. As provas sintéticas com role proprietária e cadastros preparados conservam o seu âmbito, não provam esse arranque vazio/least-privilege. Catálogo funcional está implementado na candidata, ainda não instalado; equipamentos/peças são fora do âmbito básico. Uma só concessão administrativa no destino, sem segundo administrador para entrega/activação. Não contornar com fixtures, SQL manual de negócio, permissões DDL no runtime ou orçamento desactivado por conveniência.
+
+Matriz reconciliada com **aceitação técnica expressa da navegação modular em 02/11**; pendências visual/associações/volume separadas, sem repetir suites. Próxima acção técnica: resolver/ensaiar as lacunas de implantação e só então apresentar operação exacta para confirmação; não presumir instalação/activação pelo pedido de decisão. SGF continua 0.1.1 e candidata 5fae7cf7f99c42ba imutável.
+
+## Reconciliação do estado da navegação modular SGF — 02/11/2026
+
+Na continuação após a candidata de updates, conferidos os documentos próprios do SGF `MODULAR_NAVIGATION_DELIVERY`, `MODULAR_NAVIGATION_COMPLETION`, `MODULAR_NAVIGATION_VISUAL_REVIEW` e `proofs/modular-navigation-recheck-20261102.json`. Existe resultado técnico documentado: seis módulos, 185 testes/3.220 asserções, suite focada de 8/90 e três perfis isolados, com retoma de integridade já registada no SGF. As referências anteriores de Compras a «sem resultado apresentado» são agora históricas e sucedidas por este registo e pela matriz actualizada. Não foram repetidas suites, comparação de 184 ficheiros ou provisionamento nesta leitura documental.
+
+Revisão visual/recargas, reconciliação de associações antigas e volume/paginação mantêm pendências próprias; não confundir navegação implementada com processos comerciais completos ou aceitação. A interface chama à configuração geral «Opções gerais». Sem alteração de código/dados/permissões, navegador ou instalação de candidata; SGF 0.1.1 preservado. A decisão de instalar a candidata continua separada. [Matriz reconciliada](transferencia-sgf.md).
+
+## Transição versionada e caches Laravel nativos — 02/11/2026
+
+`CodeDeployment` evoluiu para actualização de um bundle instalado sob o mesmo contrato: registo privado da versão seleccionada, plano ligado ao estado actual, arquivo do bundle anterior, reversão local exacta, recusa de reversão fora de ordem/replay antigo, novas tentativas sem sobrescrever journals e verificação de origem/conteúdo extra/schema/baseline. Diferenças em schema/catálogo/composição exigem plano próprio. Bundle antigo sem proveniência não é adoptado implicitamente. Ponteiro purchasing 0.1.1 continua separado e preservado.
+
+Acção `cache` usa Artisan nativo (`config:cache`, `route:cache`, `event:cache`) pelo PHP fixado, arquiva/invalida caches antes de compilar e regista resultados parciais/hashes. Reversão de caches recompilados invalida-os para voltar a compilar na versão reposta; não reintroduz configuração compilada antiga. Retry de rollback não mexe nos caches reconstruídos da versão anterior. Caches posteriormente alterados são preservados por recusa de recuperação cega. Caches personalizados mantêm contrato separado.
+
+Prova nova [sgf-update-cache-delivery.json](proofs/sgf-update-cache-delivery.json): **32 controlos aprovados**, transição desde `10dce6cd57fa1f31`, falha de troca, nova tentativa, caches nativos completos, compilação interrompida/cache inválido recuperados, CLI e reinícios de servidor PHP de teste. Verificados 503 de manutenção, 401 na rota em cache, 404 após retirar módulo e healthcheck 200. Sem suites anteriores repetidas, intervenção em serviços/bases/direitos públicos do destino ou publicação. Candidata `OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba` e ZIP verificável registados na prova e na [matriz/procedimento](transferencia-sgf.md). Dependencies reutilizadas por hardlinks apenas de cópia anterior descartável, nunca do vendor real. SGF de destino permanece 0.1.1. Próxima acção é revisão da candidata/procedimento e decisão separada de instalação; não activar por consequência do desenvolvimento. Reorganização modular posterior continua com entrega própria pendente.
+
+## Aplicação e reversão do código em manutenção — 02/11/2026
+
+Implementados `CodeDeployment` e `scripts/sgf-code-deploy.php`, com plan read-only, conferência de destino/manifesto/baseline, lock exclusivo, backups/journal duráveis, preparação/troca dos ficheiros e bundle, invalidação/restauro de caches padrão e recuperação de aplicação parcial. Exige manutenção de ficheiro e declaração --offline do operador; não termina/reinicia serviços, não abre manutenção, não activa configuração, não concede direitos e não abre base de dados. Recusa cache personalizado, ficheiros locais alterados e caches recriados; mantém estado para reconciliação em vez de sobrescrever. Primeiro baseline apenas; transição entre versões de bundle existente ainda pendente. Não confundir rename individual/journal com transacção multi-ficheiro: os serviços devem permanecer parados.
+
+`scripts/sgf-code-deploy-proof.php`, executado pelo wrapper/PHP 8.4.27: **42 controlos novos aprovados**, CLI real e quatro cópias descartáveis só de ficheiros, incluindo interrupção durante sobreposições/depois da troca do bundle, recuperação, rejeição de concorrência/alterações locais/cache divergente e ponteiro preservado. [Prova](proofs/sgf-code-deploy-delivery.json), candidata `OPERATIONAL-CANDIDATE-10dce6cd57fa1f31`. Sem DB, browser, reinício de serviços ou repetição das suites aceites. Nenhum código/schema/concessão/ponteiro alterado no SGF de destino; permanece 0.1.1. Procedimento exacto e limites actualizados em [transferencia-sgf.md](transferencia-sgf.md). Próximo trabalho técnico: transição versionada do novo bundle e conferência dos caches nativos/gestor de serviços numa instalação isolada, sem aplicar no destino nem presumir activação.
+
## Composição de arranque e prova HTTP isolada SGF — 02/11/2026
Implementados `BootOverlay`, `Composition`, `RuntimeContext` e entry point local do bundle. A sobreposição do provider nativo carrega classes e rotas do módulo; descoberta de Compras reconhece `purchasing_requisitions.view`. Middleware específico é acrescentado depois da sessão/autenticação/contexto nativos e confere opção, destino/schema/âmbito, módulos adquiridos e selecção/geração do contexto. Autoridade inicial mantém âmbito administrativo, sem exigir direito funcional ainda não atribuído. A CLI aceita/verifica o entry point `bootstrap.php` do bundle. Candidatas anteriores não foram reescritas.
@@ -1810,3 +1862,298 @@ Limites explícitos: autorização NÃO reserva fisicamente stock exclusivo; out
Instalação da migração48 apenas em8005 via --warehouse-events: backup e restauro verificados,108 tabelas existentes preservadas, nova tabela vazia e sem contas/grants/dados sintéticos. proofs/warehouse-fulfilment-install.json. Regressão dirigida passou85 testes/495 asserções; PostgreSQL descartável12 testes/122 asserções, mais duas disputas reais de bloqueio (mesma chave e chaves diferentes para a mesma entrega), passaram sem movimento duplicado. Tipos, lint/formatação, compilação, análise PHP,18 ficheiros dos núcleos e HTTP de consulta passaram. Bateria geral a concluir, resultado será acrescentado. Sem revisão visual por capturas; não declarar matriz integral da proposta concluída.
**Verificação final da autorização/entrega:** bateria geral908 testes/906 passaram/2ignorados/9508 asserções, sem falhas. PostgreSQL12/122 e2disputas reais já descritos; prova consolidada docs/proofs/warehouse-fulfilment.json. O acesso operacional exige atribuição explícita das tarefas warehouse_request_prepare/review/deliver e, para entregar, warehouse. Sem atribuições automáticas. Não se confundem estes resultados com reserva exclusiva ou geração automática de compras, que continuam pendentes.
+
+## 02/11/2026 — Reserva física e reposição automática parametrizada
+
+Este incremento substitui as pendências de reserva exclusiva e geração de compra descritas no registo anterior de autorização/entrega; não substitui decisões, requisições, políticas ou provas históricas.
+
+A migração49 acrescenta warehouse_reservations, diário imutável com vínculo à requisição, linha, autorização, operador e comando. Autorizar reserva material utilizável livre, sem lançar saída. Na autorização parcial só se reserva a parte disponível. A entrega consome exactamente a sua reserva e lança o movimento no StockWorkflow, na mesma transacção do evento e auditoria. Cancelar o saldo exige revisão departamental independente do requisitante e liberta só a reserva ainda não consumida; entregas e recepções anteriores são conservadas. Actualizar reserva permite comprometer material que entretanto entrou, dentro da autorização e do saldo original. Pedidos autorizados antes desta instalação exigem esta actualização explícita, sem reserva retroactiva silenciosa.
+
+O único StockWorkflow protege compromissos também nas saídas avulsas, transferências, correcções aprovadas, devoluções/abates e estornos que reduzam material utilizável. Quarentena, validade e retenções de qualidade continuam independentes: podem tornar material reservado indisponível, mas a entrega é impedida e o défice aparece na disponibilidade. Uma devolução ou estorno físico não reabre automaticamente a requisição nem recria reserva consumida. Reservas de SupplyCoordination continuam a ser planeamento e não são somadas como compromissos físicos.
+
+Novo menu Armazém e activos > Reposição automática, /stock/reposicao. A regra reutiliza a política aprovada do artigo/armazém: mínimo, alvo, máximo e múltiplo de embalagem. Requisitante, centro de custo, prazo pretendido e vigência são parametrizados uma vez; reutiliza preço em MZN do acordo e imposto da empresa quando disponíveis, permitindo estimativas fundamentadas. Peças publicadas no catálogo reutilizam a peça cadastrada e exigem equipamento compatível no departamento do requisitante; uma origem com destino técnico diferente fica pendente para reconciliação. Equipamento novo e serviços mantêm os seus percursos próprios. Não se assume destino, pessoa, preço, conversão de unidade, taxa cambial ou competência ausentes.
+
+A preparação usa supply_prepare e a activação/suspensão usa supply_review. Todos os preparadores da regra ficam impedidos de a activar. Alterar a regra exige nova activação e aplica-se às gerações futuras. O catálogo de tarefas dos perfis explicita estas responsabilidades. Sem grants ou contas adicionais. A regra activa permite somente preparar um novo rascunho pelo motor InternalRequisitions/RequisitionWorkflow em nome do requisitante parametrizado: revalida competência vigente no contexto interno e centro/departamento, conserva o executor autenticado na auditoria e não muda a autenticação para fingir acção pessoal do requisitante. A configuração não permite submeter, aprovar, reservar orçamento, adjudicar, encomendar ou lançar saída automaticamente.
+
+Após confirmação das alterações de stock, lotes, pedidos ou requisições de compra associadas, a avaliação corre sob o mesmo bloqueio de empresa. Considera stock utilizável menos compromisso físico, saldo ainda não reservado dos pedidos e reposição efectiva já em curso. Calcula a quantidade com aritmética inteira e regras aprovadas; embalagens que excedam o máximo ficam pendentes. Reutiliza compras automáticas anteriores, sugestões convertidas e entregas confirmadas ligadas ao local, sem contar duas vezes a mesma ordem. Recebimentos aceites efectivos, incluindo estornos, reduzem a cobertura pendente. Rascunhos alterados de artigo/repartidos exigem reconciliação. Uma compra rejeitada não é recriada pela mesma regra: exige revisão e activação de nova versão. Compras manuais sem vínculo autorizado a este artigo/armazém não têm o destino inferido a partir do nome.
+
+As necessidades e regras usam versões imutáveis de supply_records, sem outra estrutura paralela. A requisição gerada conserva ligação à necessidade e mostra as requisições ao armazém de origem, local de utilização e equipamento; versões posteriores não reescrevem essa origem. Sem configuração válida, regista necessidade pendente e motivo. GET apenas consulta. Reavaliar é uma intervenção explícita de preparação/revisão. Uso interno continua sem factura; venda e integração de facturação pertencem à composição SGF.
+
+Instalação49 aplicada exclusivamente à avaliação8005, com backup/restauro conferidos e109 tabelas existentes preservadas; nova tabela vazia, sem contas, permissões ou dados sintéticos. Prova: docs/proofs/warehouse-reservations-install.json. A avaliação existente foi retomada porque a porta estava desligada, sem provisionar outra base ou redefinir contas.
+
+Verificações: bateria geral920 testes,918 passaram,2 anteriormente ignorados,9620 asserções, sem falhas; storage/logs/warehouse-reservations-full.txt. PostgreSQL descartável24 testes/232 asserções e três disputas reais de bloqueio passaram: repetição da mesma entrega, comandos diferentes da mesma entrega, activação concorrente da regra com um só rascunho. Prova em warehouse-drafts-pg.json. Compilação, tipos, lint/formatação, análise PHP e integridade dos18 ficheiros dos núcleos passaram; acesso HTTP à página de reposição confirmado como administrador com consulta, sem preparação/revisão ou comandos de negócio. Prova consolidada em warehouse-replenishment.json. Sem revisão visual por capturas e sem declarar todos os CA01–09 cumpridos.
+
+Limites e continuidade: preenchimento de reservas após reposição é explícito pelo revisor; não foi criada política automática de prioridade entre pedidos concorrentes. Avaliação automática é posterior à confirmação das alterações, com bloqueio/idempotência e reavaliação pela interface; ainda não existe fila durável com recuperação automática de interrupção entre confirmação física e avaliação, nem varrimento agendado para passagem do tempo/validade sem qualquer evento. Necessidades podem ser reavaliadas pela tarefa autorizada. Prazos de expiração de reservas, revisão de pedidos já submetidos, avisos próprios do percurso de armazém e composição comercial SGF continuam a exigir incrementos específicos; nenhuma alçada ou canal foi inventado.
+# 02/11/2026 — candidata SGF corrigida, exclusivamente isolada
+
+Preparada `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`: schema revisão 2 inclui as duas dependências `cb_ui_commands`/`cb_consumption_sources` (16 tabelas novas); preparação autorizada de centros/dotações pela interface, catálogo administrativo integrado no frontend compilado e plano SQL mínimo separado da ligação migradora. Nenhum dado sintético distribuído. [Decisão e procedimento](decisao-instalacao-sgf.md), [matriz](transferencia-sgf.md), [prova](proofs/sgf-corrected-delivery.json), [SQL proposto, não executado](proofs/sgf-corrected-sql-plan.json).
+
+157 controlos passaram na reprodução do schema actual de 87 tabelas, com owner/runtime restritos distintos: migração/falha, percurso básico/independência/reserva, catálogo/navegação, revogação/contexto, permissões SQL e actualização/reversão. Restauro em segunda base reconcilia 103 tabelas, schema/triggers e origem/decisão/reserva; apenas dados sintéticos, bases/roles removidas. Provas anteriores válidas reutilizadas. ZIP SHA-256 `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703`.
+
+SGF permanece **0.1.1**; nenhuma instalação, concessão/revogação, mudança de base/configuração ou publicação. Pendências: confirmação separada da operação e SQL, backup/restauro real na janela aprovada, configuração real e dois administradores para activação. Equipamentos/peças fora do âmbito; navegação modular com aceitação técnica anterior reconciliada. Preservado trabalho local alheio ao incremento.
+
+
+
+### 02/11/2026 ? candidata SGF utiliz?vel, ainda n?o instalada
+
+Conclu?das depend?ncias or?amentais de interface, administra??o pelo cat?logo nativo e prepara??o de centros/dota??es. Candidata 401ce31ae5f2dd20, schema revis?o 2; 157 controlos integrados + 17 React/render/assets + 54 deployment. Restauro efectivo de 103 tabelas e actualiza??o/recupera??o de c?digo sobre dados sint?ticos verificados. Ver docs/transferencia-sgf.md e a proposta revista no SGF. N?o executar a proposta antiga 5FAE. SGF continua 0.1.1, sem tocar em sgf_tempo_dev ou conceder direitos; instala??o/configura??o e mapa SQL s? para decis?o posterior. Preservar trabalho paralelo; equipamento/pe?as fora, revis?o visual/associa??es antigas/volume-pagina??o pendentes.
+
+## Avaliação do estado do SGC — 02/11/2026
+
+Avaliação de código, registos de implementação, provas existentes e acesso HTTP; sem alteração de regras ou dados operacionais. Diagnóstico: base funcional ampla, adequada a piloto controlado após configuração, ainda em consolidação para entrega comercial. Compras, Pessoas/relatórios e armazém têm percursos implementados; parametrização integral, automatização resiliente, avisos de armazém e composição comercial não devem ser declarados concluídos.
+
+Verificação executada nesta avaliação:33 testes dirigidos passaram,314 asserções (AutomaticPurchases, WarehouseFulfilment, WarehouseAvailability e WorkContext);18 ficheiros comuns conferidos por SHA-256. HTTP confirmou reposição/avisos e relatórios de Pessoas com CSV na avaliação8005. A bateria geral920/918 passaram/2ignorados/9620 asserções é prova anterior reutilizada, não nova execução integral nesta avaliação. Não houve auditoria visual completa, teste de carga ou confirmação de paridade com produção.
+
+Prioridades identificadas por leitura do código: AutomaticPurchases::signal usa afterCommit sem fila durável; interrupção entre confirmação física e avaliação pode exigir reavaliação explícita. Não existe varrimento agendado de validade/mínimo sem evento. AutomaticPurchaseController limita regras/necessidades/histórico a100 registos sem paginação, podendo omitir pendências mais antigas; avaliação percorre pedidos/históricos sob bloqueio de empresa, pelo que capacidade com volume não está demonstrada. Sequência de papéis de aprovação permanece parcialmente definida no código, apesar de limites e permissões configuráveis. Completar avisos/prioridades/expiração de reservas e validar tarefas/ecrãs/impressão com utilizadores antes da entrega. Integrações SGF em demonstração/candidata não equivalem a instalação no SGF real; a documentação própria conserva os respectivos limites e autorizações.
+
+## Piloto controlado SGC — reposição resiliente e paginação — 02/11/2026
+
+Prioridade confirmada pelo proprietário: consolidar Compras autónomo para piloto, começando pela recuperação de reposição após interrupções, verificação periódica de mínimos/validade e paginação; depois acompanhamento das reservas e validação com utilizadores. O incremento não altera a instalação real SGF0.1.1, não instala candidatas SGF e não cria a identidade sintética adicional cuja proposta continua sem autorização. Os33testes/314verificações e918aprovados/2ignorados da avaliação anterior continuam provas históricas, sem serem apresentados como execução nova ou revisão visual completa.
+
+Migração50: replenishment_work, um trabalho durável por empresa/artigo/armazém, com geração do sinal, geração processada, responsável da origem, próxima execução, tentativas e erro técnico sanitizado. O sinal é inserido/actualizado na mesma transacção das alterações físicas/documentais; rollback da alteração também reverte o sinal. Sinais repetidos são acumulados no mesmo trabalho e a avaliação lê os dados actuais. A criação do rascunho pelo motor existente, origem imutável e confirmação de processamento são atómicas, sob bloqueio de empresa e trabalho. Interrupção antes da confirmação reverte tudo; interrupção após confirmação física mas antes da avaliação deixa trabalho recuperável. Não há reserva de identidade humana, concessão de competências, aprovação por tempo decorrido ou novo motor financeiro.
+
+ReplenishmentRun é um âmbito restrito a CLI, sem sessão/utilizador autenticado, composição SGC e empresa do trabalho persistido. Não modifica a autenticação nem cria conta técnica. A auditoria identifica scheduled_replenishment, trabalho/empresa e pessoa responsável pelo sinal/regra como atribuição de origem, não como autora de uma intervenção interactiva. O histórico no detalhe da requisição distingue execução periódica. CurrentActor mantém a validação normal de todos os comandos humanos; só o novo rascunho de reposição sob regra aprovada pode usar o âmbito restrito. Requisitante, centro, política, catálogo, destino técnico e contexto continuam revalidados. Contexto empresarial desligado ou produto fora da composição SGC impede execução. Autoridade de origem pode mudar enquanto um recuperador espera: o responsável é novamente conferido no trabalho sob o bloqueio da empresa.
+
+sgc:replenishment-tick descobre pares de políticas/regras/necessidades e origens de armazém já existentes, recuperando também a janela anterior à instalação, e processa trabalhos vencidos por lotes. O agendamento Laravel está declarado a cada minuto; cada trabalho tem próxima verificação persistida, por defeito5minutos, configurável em SGC_REPLENISHMENT_SCAN_MINUTES. É uma cadência técnica, não prazo de aprovação. Falhas técnicas mantêm o sinal e voltam a tentar com espera limitada; situações funcionais sem configuração válida permanecem necessidades pendentes visíveis e voltam a ser conferidas. Não é preciso um novo movimento físico para detectar validade de lote/regra ou stock mínimo. A elegibilidade de qualidade continua independente e nunca se liberta lote para satisfazer uma reserva.
+
+/stock/reposicao pagina separadamente regras, necessidades, histórico e trabalhos técnicos pendentes/interrompidos. Necessidades pendentes aparecem primeiro, com filtro de estado; os registos antigos deixaram de desaparecer pelo limite fixo100. Os trabalhos sem necessidade apresentada ainda são visíveis por artigo/armazém, motivo sanitizado e próxima tentativa. Reavaliar agora exige supply_prepare ou supply_review; administrador só de consulta não executa a acção. Links preservam parâmetros e o servidor filtra empresa, permissões e contexto. GET não cria compras. A interface mostra intervalo, contadores e data/resultado da última passagem conhecida do serviço, sem afirmar serviço activo a partir da mera instalação.
+
+Instalação50 apenas na avaliação8005: backup/restauro verificados,110 tabelas anteriores preservadas, nova tabela inicialmente vazia, sem contas, grants ou dados sintéticos adicionados. Depois, o serviço descobre e acompanha apenas origens já existentes. Prova replenishment-recovery-install.json. scripts/replenishment-evaluation.php valida a configuração/base isolada existente e mantém verificação periódica com exclusão local, executando processos CLI frescos e estado técnico sem segredos. Arrancado oculto nesta sessão e observado em passagens sucessivas; integrado no arranque de desenvolvimento existente. Não é um serviço Windows instalado para arranque após reinício. Retomar composer run dev ou o auxiliar guardado depois de reiniciar o ambiente; em instalação comercial, preparar o agendador da instalação na respectiva janela autorizada.
+
+Novas provas, separadas das anteriores: regressão alargada198testes/1579asserções passou; verificação final da reposição18testes/155asserções passou; PostgreSQL descartável33testes/310asserções e4disputas reais passaram, incluindo saída física confirmada seguida de término controlado do processo no início do callback e dois recuperadores concorrentes, com um único rascunho e sem humano autenticado. Também provados rollback de rascunho/checkpoint, rollback de sinal físico, validade sem movimentos, paginação de105pendências antigas, isolamento entre empresas, impossibilidade de usar âmbito de background numa sessão e bloqueio na composição SGF. Tipos, lint/formatação, compilação, análise PHP dos componentes alterados,18hashes e HTTP com paginação/estado recente do serviço passaram. Logs replenishment-pilot-regression.txt e replenishment-pilot-final-tests.txt; provas replenishment-pilot-pg.json, replenishment-pilot-http.json e consolidada replenishment-pilot.json. Não foi repetida a bateria geral anterior nem efectuada revisão visual completa ou aceitação por pessoas reais.
+
+### Roteiro preparado para aceitação do piloto — ainda não executado com utilizadores
+
+Usar contas e competências existentes, nominativas e autorizadas pela empresa; não criar identidades para ultrapassar independência. Identificar previamente quem requisita, quem autoriza e quem entrega. O requisitante confirma recepção. Esta distribuição do SGC autónomo não concede novas identidades/competências no SGF.
+
+| Caso | Acção do participante | Resultado a conferir |
+| --- | --- | --- |
+| Pedido interno | Requisitante selecciona artigo, centro e local/equipamento aplicável | Origem reutilizada; sem factura e sem saída/reserva no rascunho |
+| Autorização | Outro responsável decide; aceita parcial apenas se o requisitante a tiver aceite | Reserva só do material utilizável livre; decisão e autoria preservadas |
+| Entrega parcial | Armazém, distinto de requisitante e autorizador, confirma quantidade real | Só a quantidade entregue sai; reserva restante e pendência ficam visíveis |
+| Recepção | Requisitante confirma uma entrega concreta | Não há segunda saída física |
+| Cancelamento de saldo | Responsável competente fundamenta o cancelamento | Liberta só o restante; entregas/recepções anteriores preservadas |
+| Falta/mínimo | Necessidade numa regra já activada por responsável independente | Um rascunho para o requisitante configurado; nenhuma aprovação automática |
+| Recuperação | Conferir trabalhos interrompidos e repetição de reavaliação | Um rascunho associado; erro/pendência visível e tentativa seguinte registada |
+| Validade | Conferir lote/regra que deixa de ser válido | Sem saída de lote indisponível; nova avaliação sem movimento; pendência fundamentada |
+| Pendências antigas | Filtrar e percorrer páginas | Conseguir localizar o pedido antigo sem conhecer o seu ID |
+
+Continuidade: completar acompanhamento próprio das reservas (avisos/tarefas, prioridades e expiração expressamente parametrizadas), executar este roteiro com utilizadores existentes e registar observações por percurso. Não declarar aceitação humana, desempenho em grande volume ou cobertura comercial SGF com base nestes testes técnicos.
+
+## Acompanhamento das reservas e validação sintética — 02/11/2026
+
+Clarificação posterior do proprietário: nesta fase não criar pessoas reais; continuar a desenvolver e validar com contas sintéticas, tendo em vista a comercialização após o desenvolvimento. Orientação incorporada em AGENTS.md. A consulta de utilizadores da empresa na avaliação8005 confirmou contas sintéticas já existentes; não foram criadas contas, alterados perfis ou concedidas competências nesse ambiente. A validação funcional adicional usa actores/perfis sintéticos explícitos em bases descartáveis. Não confundir estes ensaios com aceitação por utilizadores reais, configuração de uma empresa cliente ou autorização da identidade adicional proposta no SGF. SGF real permanece fora deste incremento, em0.1.1.
+
+Novo WarehouseWorkdesk, exclusivamente de leitura, sobre WarehouseRequest, WarehouseRequestEvent e warehouse_reservations existentes. /stock/requisicoes reúne tarefas por competência efectiva: autorizar, acompanhar reservas abertas, entregas pendentes e recepções por confirmar, além da consulta do âmbito permitido. Não cria uma segunda lista de concessões ou outro workflow. Tarefas desaparecem/reaparecem com o estado e a autoridade actuais; requisição própria não é autorizada pelo seu autor, e quem autorizou não se torna entregador do mesmo pedido. Revogação/vigência de perfil e departamento/contexto continuam aplicáveis. Administração só de consulta não recebe tarefas operacionais por consultar o painel.
+
+Filtros por tarefa, estado, armazém, referência RA-id, requisitante ou local são aplicados no servidor antes da paginação. Contagens abrangem todo o conjunto autorizado, sem depender da página ou dos últimos100registos; ordem de registo, mais antigos primeiro, não é uma prioridade automática de afectação de stock. A projecção SQL de estados deriva dos eventos e quantidades preservadas, sem escrever saldos/estados paralelos. Foi confrontada com WarehouseFulfilment::progress nos estados rascunho, submetida, autorizada, parcial, entregue, encerrada, rejeitada e cancelada, em SQLite e PostgreSQL. As decisões/movimentos continuam a usar os comandos e o único StockWorkflow existentes.
+
+Cada pedido apresenta saldo reservado/sem reserva e avisos de acompanhamento. Lotes não reconciliados, défice global de material utilizável comprometido, alteração da origem/destino e saldo ainda sem reserva ficam explícitos, sem divulgar novas quantidades globais a quem só consulta o seu documento. A indicação de prontidão é momentânea, não uma garantia de saída: o servidor revalida versão, origem, reserva, qualidade e disponibilidade no registo efectivo. O formulário de entrega só é apresentado com material reservado elegível no momento da consulta; não se liberta qualidade nem se autoriza entrega para satisfazer o aviso.
+
+O detalhe mostra pessoas actualmente elegíveis para requisição/recepção, autorização/reserva e entrega, reutilizando perfis e âmbito reais do servidor. Ausência de pessoa elegível é visível e exige parametrização explícita, sem nome ou tarefa presumidos. Esta informação actual não reescreve autores históricos e fica fora da impressão do documento de saída. A confirmação de recepção pendente continua como tarefa mesmo depois de cancelar o saldo não entregue; confirmar não lança outra saída. Pedidos fechados/cancelados não são reabertos por mera consulta ou estorno físico.
+
+O formulário de nova requisição ficou recolhido até ser solicitado e abre em caso de erro, para deixar o acompanhamento visível sem acrescentar campos obrigatórios ao percurso. O catálogo de tarefas nos perfis explicita preparação/confirmação e entrega independente, usando as permissões existentes. Lista e detalhe usam leitura coerente em transacção PostgreSQL read-only/repeatable-read; consultas não geram compras, avisos persistentes ou movimentos. Este incremento acrescenta avisos visíveis no acompanhamento, não envio externo nem notificações com estado de leitura. Não foram inventadas prioridades organizacionais, prazos de expiração ou libertações automáticas de reserva.
+
+Validação NOVA com contas sintéticas:111testes/1008asserções na regressão dirigida;18testes/326asserções na verificação final de pedidos; PostgreSQL descartável38testes/517asserções e3disputas reais reutilizando os cenários de entrega/compra, sem duplicação. Cobertura específica inclui percurso completo parcial/recepção, cancelamento com recepção ainda pendente, ausência/revogação de entregador elegível, separação de funções, filtros antes da paginação, pesquisa de pedido antigo, âmbito empresarial/departamental/armazém e impedimento de saída de lote retido. Tipos, lint/formatação, compilação, análise PHP,18hashes e HTTP de consulta passaram. Provas separadas: warehouse-workdesk.json, warehouse-workdesk-pg.json, warehouse-workdesk-http.json; logs warehouse-workdesk-regression.txt e warehouse-workdesk-final.txt. Sem migração, dados sintéticos adicionados à avaliação persistente, concessões ou instalação SGF; sem nova execução da bateria geral anterior e sem declarar revisão visual completa.
+
+Continuidade: continuar validação sintética e consolidar cenários de acompanhamento antes da comercialização. Se forem acrescentadas prioridades, prazos de expiração ou notificações persistentes, preparar configuração explícita e revisão independente dos efeitos; não usar o tempo decorrido para aprovar, entregar ou libertar material por inferência. A configuração funcional das contas da avaliação deve continuar explícita e separada dos perfis ensaiados em bases descartáveis.
+
+## Preparação do piloto e ensaio sintético integrado — 02/11/2026
+
+Adicionado /stock/piloto, menu Armazém e activos > Preparação do piloto, reservado a administração. WarehousePilotReadiness é diagnóstico somente de leitura, em transacção coerente PostgreSQL read-only/repeatable-read. Apresenta componentes instalados, tarefas e independência, existência de centro no departamento, catálogo publicado com origem actual, regras activas e fontes válidas, execução periódica observada, trabalhos a recuperar e configuração própria da empresa. Mostra contas cadastradas e pessoas com tarefas elegíveis, com ligações à parametrização existente. Não cria contas, atribui perfis, activa regras, lança movimentos ou aprova compras.
+
+O diagnóstico distingue condições básicas para ensaio de armazém, reposição periódica e configuração de cliente. Há verificação de três intervenientes distintos, requisitante/autorizador do mesmo departamento e centro de custo compatível; distribuir os três rótulos por apenas duas pessoas não basta. Administração permanente segue a regra existente de dois administradores nominativos, fora de manutenção e sem prazo. Compatibilidade com papéis de desenvolvimento e contexto de avaliação não são certificados como configuração de cliente concluída. Falta de stock não é tratada como erro de configuração, pois é precisamente um cenário a ensaiar.
+
+AutomaticPurchases::inspectRule reutiliza a validação de requisitante/centro e as mesmas fontes/políticas/catálogo/acordo/destino técnico da geração, mais os limites de preço/imposto do formulário canónico. A parte comum de validação do requisitante foi separada da aquisição de locks, preservando-os nos comandos e permitindo inspecção sem escrita. Não executa o comando de geração nem substitui autenticação para obter um resultado. Regra expirada, origem alterada, perfil revogado e centro incompatível deixam de contar como válidos. Regras/fontes mudadas continuam a exigir revisão explícita, sem actualizar os documentos históricos.
+
+Estado do serviço periódico centralizado em ReplenishmentQueue::status e reutilizado na reposição e no diagnóstico. Telemetria opcional inexistente/danificada não bloqueia a consulta; registo antigo ou de futuro não prova serviço activo. Uma passagem recente com resultado favorável não oculta sinais técnicos por processar nem trabalhos em recuperação: esses impedem o indicador de reposição pronta. Necessidades funcionais sem configuração continuam no acompanhamento de reposição. Não foi acrescentado prazo de aprovação ou expiração de reserva; a recência é apenas observação técnica do serviço.
+
+Ensaio integrado novo em WarehousePilotJourneyTest: três actores sintéticos, artigo publicado, política e parametrização com conferência independente; pedido interno125unidades com120utilizáveis, reserva parcial120 e um rascunho canónico de reposição108 conforme embalagem/alvo do ensaio. Entrega100, confirmação, entrada108 numa ordem já emitida usada como origem física independente, reforço autorizado de reserva5, entrega final25 e recepção encerram o pedido. Saldo físico final103, reserva0, sem factura de saída interna e sem segundo rascunho. A requisição automática continua rascunho, com zero etapas decididas e sem reserva orçamental automática. Este ensaio não apresenta a chegada da ordem preexistente como execução/aprovação do novo rascunho de compra: a preservação do circuito financeiro é parte da verificação.
+
+Testes do diagnóstico cobrem leitura sem alteração de dados/perfis/auditoria, administração exclusiva, isolamento empresarial, combinação de papéis insuficiente, cadastro desactualizado, regra expirada/requisitante sem competência, telemetria antiga/corrompida e passagem recente que não esconde recuperação pendente. As alterações de ambiente/ficheiro necessárias ao teste de telemetria ficam em armazenamento temporário próprio, restaurado no final; não substituem o estado do serviço da avaliação persistente.
+
+Provas NOVAS: regressão53testes/705asserções; final diagnóstico+percurso8testes/142asserções; PostgreSQL descartável46testes/659asserções e3disputas reais preservando entrega e rascunho únicos. Tipos, lint/formatação, compilação, análise PHP dos componentes alterados,18hashes e HTTP passaram. Provas warehouse-pilot-readiness.json, -pg.json e -http.json; logs warehouse-pilot-readiness-regression.txt e -final.txt. Suites sobrepostas não devem ser somadas. Não houve bateria geral anterior repetida, migração, alteração de concessões/contas persistentes, criação de pessoas reais, instalação SGF ou revisão visual completa.
+
+Observação da avaliação8005 nesta conferência:12contas sintéticas cadastradas; instalação e passagem recente do serviço conferidas, recuperação técnica sem sinais de erro na consulta; tarefas/independência, catálogo, parametrização de reposição e configuração própria de cliente ainda apresentam pendências. O diagnóstico conserva esses resultados em vez de dar a avaliação por pronta ou preencher configurações de forma presumida. SGF real continua fora deste incremento, em0.1.1, sem autorização inferida para a identidade adicional.
+
+## Diagnóstico do piloto exportável — 02/11/2026
+
+/stock/piloto passa a oferecer exportação CSV das conferências e opção de impressão pelo navegador. A exportação em /stock/piloto.csv exige a mesma administração autenticada da consulta, lê o mesmo WarehousePilotReadiness em transacção coerente/read-only e inclui data, âmbito SGC autónomo, conferência, resultado, observação e ligação à configuração. Não exporta o cadastro de contas nem executa os passos do roteiro. Cada exportação reflecte a sua própria data de leitura, podendo diferir de uma página anteriormente aberta; não é aprovação, activação, certificado de entrega ou arquivo imutável do servidor.
+
+CSV UTF-8, separado por ponto e vírgula, com resposta privada/no-store e tratamento de texto que possa ser interpretado como fórmula por leitores de folhas de cálculo. As verificações incluem recusa a leitor operacional sem administração, ausência de alteração de utilizadores/perfis/auditoria/stock/requisições e neutralização de campos semelhantes a fórmulas. A opção de impressão foi acrescentada à interface, sem declarar revisão visual de impressão.
+
+Provas NOVAS:10testes/161asserções passaram no diagnóstico/percurso e exportação, tipos/lint/build/análise PHP e18hashes passaram; CSV conferido no HTTP da avaliação8005. Logs pilot-export-tests.txt e pilot-export-build.txt; provas pilot-export.json e pilot-export-http.json. Sem migração, contas, pessoas reais, concessões, configurações financeiras ou instalação SGF. As pendências de preparação já identificadas continuam explícitas; o relatório permite revê-las sem preenchimento presumido. Evidências anteriores e ausência de nova bateria geral/revisão visual completa preservadas.
+
+## Interface reutilizável de entregas do armazém — 02/11/2026
+
+Implementado WarehouseExchange e GET /stock/integracao/entregas, na aplicação SGC autónoma. É uma interface de leitura dos eventos imutáveis existentes, sem tabelas novas, cópias de stock, concessões ou comandos empresariais. Contrato de dados versionado em integrations/contracts/warehouse-fulfilment-v1.json. O serviço pode ser reutilizado na composição autenticada; a rota usa a sessão, verificação e configuração de empresa/contexto já existentes. Não é uma API pública nem autenticação técnica entre servidores.
+
+Publica apenas deliver e receive: entregas físicas efectivas e confirmações do requisitante. Reserva, autorização e cancelamento de saldo não são publicados como saídas. Cada entrega transporta RA, empresa, versão do evento, data UTC, interveniente, armazém, centro, requisitante e destino originais. Nas linhas reutiliza artigo/unidade, quantidade inteira em milésimos, utilização, equipamento e stock_operation_id do único motor StockWorkflow. A confirmação referencia a chave da entrega e não repete linhas/movimento. Alterações posteriores ao cadastro não reescrevem estes dados. A requisição interna continua documento de saída sem factura.
+
+Leitura incremental: limit entre1e100 (por defeito50), cursor opaco devolvido em next_cursor e has_more. Sem cursor, começa nos eventos antigos visíveis; mesmo no fim devolve cursor para futuras consultas. O cursor é cifrado/autenticado, ligado à empresa, conta, contexto, departamento e competências efectivas; mudança desse âmbito exige reiniciar leitura. Cada página revalida acesso e filtra antes de limitar; isolamento empresarial/departamental e leitura coerente PostgreSQL preservados. Não guardar cursor como autorização nem o transportar entre instalações/contas. Os comandos existentes serializam eventos por empresa, antes de se avançar por ID; não há outro escritor de eventos neste módulo.
+
+Contrato de consumo: guardar os factos recebidos e o próximo cursor na mesma transacção do consumidor, avançando só depois de os aceitar. Uma repetição deve reutilizar a idempotency_key, com unicidade por ligação à instalação de origem + tenant_id + chave. A chave não é global entre instalações independentes. payload_sha256 identifica o conteúdo produzido (JSON dos campos precedentes, na ordem emitida, sem espaços/escapes de Unicode ou barras); igualdade serve para conferir replays. Mesma chave com conteúdo diferente exige reconciliação, não sobrescrita silenciosa. O resumo não autentica um canal externo. Campos adicionais compatíveis podem ser ignorados; alteração incompatível exige outra versão de contrato.
+
+Âmbito preciso: estes são factos históricos de entrega/recepção, não sincronização de saldos nem instruções para lançar outra saída. Estornos físicos mantêm o percurso StockWorkflow e não apagam entregas antigas; não interpretar este feed isolado como espelho do stock actual. Não inclui autorizações completas, reservas actuais, catálogo integral, factura de venda, contabilidade ou contratos de Pessoas/RH. Conector remoto, credenciais técnicas, confirmação durável do consumidor e instalação no SGF são incrementos próprios, ainda não implementados por esta interface. Preservar a candidata e o SGF real0.1.1 sem inferir instalação/autorização da identidade adicional.
+
+Validação NOVA:41testes/523asserções na regressão dirigida (Exchange, Fulfilment, Requests e WorkContext); subconjunto novo10testes/83asserções também em PostgreSQL descartável, sem disputas novas ou ensaio de carga. Cobertura de repetição estável, paginação/novas consultas, alteração posterior de cadastro/equipamento, reserva sem saída, cancelamento com recepção posterior, acesso revogado, cursor adulterado/de outra conta/âmbito, limites, isolamento e consulta sem alterar diários/perfis/auditoria. Formatação e análise PHP dirigidas passaram;18hashes comuns preservados. HTTP8005 confirmou consulta, cursor e limite, com zero factos na primeira página da avaliação actual; os factos sintéticos foram exercitados apenas nas bases de testes. Provas warehouse-exchange.json, warehouse-exchange-pg.json e warehouse-exchange-http.json; logs warehouse-exchange-regression.txt e warehouse-exchange-pg.txt. Contrato JSON analisado sem erro de sintaxe; não foi usado validador externo de JSON Schema. Sem mudanças de interface visual, build novo, execução geral ou revisão visual completa. Não foram criadas contas, alterados perfis persistentes ou instalados módulos no SGF.
+
+## Receptor durável e leitura por lotes para integração — 02/11/2026
+
+Acrescentado packages/integration, independente de Laravel/SGF e sem dependências financeiras adicionais: WarehouseFeed valida o contrato v1 de entregas; WarehouseInbox mantém recepção deduplicada e checkpoint numa só transacção PDO; WarehousePull coordena consulta por lotes e retoma pelo cursor guardado. Carregamento explícito por bootstrap.php; exemplo de ligação em packages/README.md. Este incremento prepara a parte destinatária da integração; não instala o módulo no SGC persistente/SGF e não implementa transporte remoto, credenciais técnicas ou projecções de negócio no host.
+
+O host configura explicitamente instalação de origem, empresa de origem, empresa local e geração do leitor. Não equiparar IDs de empresas entre sistemas, nem aceitar estes vínculos de um pedido não autorizado. A chave deduplicada inclui origem+empresa de origem+empresa local+evento; leitores distintos nesse mesmo âmbito partilham factos e têm checkpoints separados. Mudança de conta/perfis/contexto na origem pode exigir uma nova geração de leitura, preservando a anterior e reutilizando factos já recebidos; isto é uma chave técnica de acompanhamento, não criação de outra identidade humana/sintética. Permissões, instalação e configuração do host continuam a exigir o respectivo percurso autorizado.
+
+WarehouseInbox::schema fornece duas definições de tabela para migração explícita do destinatário: sgc_exchange_streams e sgc_exchange_facts. Construção, consulta e recepção não criam schema. Compatibilidade ensaiada com PDO SQLite e PostgreSQL em modo de excepções. O receptor recusa transacção exterior activa para não confirmar falsamente um cursor antes do commit do dono dessa transacção. Não há down, remoção de factos, restauro, grants, seeds ou migrações executadas em instalações persistentes neste incremento. O adaptador não substitui o plano de instalação, endurecimento de privilégios e auditoria de acesso do host.
+
+A página completa é validada antes da escrita: versão, empresa, identidade do evento, ordem crescente, acção, intervenientes, documento interno, destino, quantidades inteiras positivas, equipamento condicional e hash do conteúdo original. Metadados adicionais compatíveis são preservados. Entrega/recepção mantêm os snapshots e vínculos de StockWorkflow; não lançam outra saída, compra, aprovação ou factura. Recepção exige entrega original já recebida, com a mesma requisição/snapshot; duas confirmações diferentes da mesma entrega são impedidas. Os factos são históricos e não representam saldo actual ou estorno implícito.
+
+Na recepção, bloquear o checkpoint e conferir versão/cursor solicitado; guardar factos e cursor atomicamente. Retry exacto da última página após confirmação perdida é reconhecido sem nova inserção. Páginas antigas, disputa de versão, mesma identidade com hash diferente e confirmação sem origem não fazem avançar a leitura. Falha numa linha posterior reverte também os factos anteriores dessa página. Duas streams que recebem o mesmo evento reutilizam o facto pela chave comum. As operações de aceitação usam apenas SQL parametrizado e âmbitos explícitos. O hash identifica conteúdo do produtor e não autentica um remetente externo.
+
+WarehousePull recebe do host uma função de consulta já autenticada, sem escolher URLs ou credenciais. Cada consulta usa checkpoint actual; limites técnicos de páginas/linhas são validados antes de chamar o transporte. Interrupção ou erro numa página conserva as anteriores já aceites; uma execução seguinte retoma a página pendente. has_more=true quando o lote atinge o limite conserva a continuação explícita. Sem agendador, chamadas externas ou associação a contas reais activados. A projecção funcional/confirmacão de consumo pelo sistema de destino continua separada do diário de recepção agora implementado.
+
+Correcção complementar na origem WarehouseExchange: cursor contém fingerprint ordenado das competências em vez de toda a lista, mantendo o vínculo de autoridade e um tamanho limitado mesmo com o catálogo completo de permissões. Cursors anteriores ao refinamento exigem reinício de leitura; os factos/chaves v1 mantêm-se. Não confundir invalidar um cursor técnico com alterar uma requisição ou conceder competências.
+
+Validação NOVA:56testes/628asserções na regressão dirigida (receptor, feed, requisições, entrega e contexto);25testes/188asserções do subconjunto em PostgreSQL descartável. Dois executores com contenção real disputaram a mesma página e páginas diferentes: um facto e um único avanço de checkpoint, com retry reconhecido ou conflito explícito. Processo terminado após commit e antes de devolver confirmação foi retomado por outro processo sem duplicar facto/avanço. Testes incluem falha a meio da página, origem/empresa local distintas, confirmação sem entrega ou repetida, hash adulterado/conflitante, versão/cursor antigos, transporte indisponível, limites de lotes, metadados e preservação dos movimentos originais. Ensaio integrado consome JSON real da rota de entregas para o receptor, em base de testes. Não somar suites sobrepostas.
+
+Formatação e análise PHP dirigidas passaram;18hashes financeiros comuns preservados. Consulta HTTP8005/cursor/limites passou, ainda com zero factos na primeira página local. Provas warehouse-inbox.json e warehouse-inbox-pg.json, HTTP em warehouse-exchange-http.json; logs warehouse-inbox-regression.txt e warehouse-inbox-pg.txt. Nenhuma bateria geral, build frontend ou revisão visual completa novos. Toda a instalação de tabelas de recepção e criação de intervenientes destes ensaios foi descartável. Sem migração, contas/perfis/grants persistentes, instalação ou identidade adicional no SGF. SGF real continua0.1.1; preservar candidatas/trabalho paralelo. Próximo recorte: transporte/configuração autenticados no host e reconciliação/projecção autorizada dos vínculos, sem duplicar o motor de stock ou presumir entrega comercial completa.
+
+## Transporte HTTP autenticado do armazém — 02/11/2026
+
+Implementado WarehouseHttpSource em packages/integration e diagnóstico WarehouseTransportFailure. O módulo destinatário já pode executar consulta HTTP real por WarehousePull com uma sessão existente e autorizada na origem. Consulta fixa GET /stock/integracao/entregas, sem endpoints/credenciais na página recebida. O host fornece origem HTTPS, empresa e fornecedor de Cookie por canal protegido. Não foi criado outro utilizador, token permanente ou motor de autenticação; conta, perfis, isolamento, contexto e verificação da origem continuam a cargo do percurso já aprovado. O cliente não faz login/POST/seleção silenciosa de contexto nem altera aprovações.
+
+Origem validada sem userinfo/query/fragmento ou travessia; query de cursor/limite codificada. Não seguir redireccionamentos impede reencaminhar a sessão para outro destino ou aceitar página de login como dados. HTTPS verifica certificado e hostname. HTTP exige opção explícita e endereço literal127.0.0.1/[::1], com proxy desligado só nesse percurso local; não existe opção de desactivar verificação TLS. Limites técnicos no construtor:15s totais/5s ligação/8MiB por defeito, com corte da resposta descomprimida; não são regras organizacionais. Credenciais consultadas por pedido, protegidas na depuração/parâmetros sensíveis, sem persistência ou apresentação em erros.
+
+Aceitar apenas HTTP200/application-json/contrato v1 da empresa esperada, com validação do produtor. Erros estruturados e sanitizados distinguem sessão, competência, redireccionamento, cursor, indisponibilidade, timeout, tamanho e conteúdo. Não incluem corpo remoto, cookie ou cadeia original de excepções. retryable identifica falhas técnicas mas não executa tentativas automáticas. Invalidar/expirar a sessão, recusar âmbito ou devolver cursor antigo conserva checkpoint/factos; retoma/renovação continua explícita. Falha na rede não aprova, entrega ou movimenta stock.
+
+Novos testes usam um servidor HTTP descartável em porta aleatória127.0.0.1, sem Laravel/contas reais, ligado a receptor SQLite ou PostgreSQL descartável. Cobrem leitura autenticada/paginada real, parâmetros codificados, consulta repetida do fornecedor de sessão, limites antes de credenciais, origens inseguras/ambíguas,401/419/403/302/409/422/429/503/404, HTML/JSON inválido, outra empresa, hash alterado, credencial indisponível/injecção de cabeçalhos, resposta excessiva, timeout e ligação recusada sem avançar o checkpoint.
+
+Provas NOVAS75testes/746asserções na regressão dirigida;44testes/306asserções no subconjunto PostgreSQL descartável com2contenções reais e interrupção após commit antes da confirmação preservadas. Suites sobrepostas: não somar. Formatação/análise PHP dirigidas e18hashes comuns passaram. Cliente nativo PHP/cURL ensaiado também contra8005 com sessão sintética existente: consulta e roundtrip do cursor passaram; leitura sem sessão válida foi recusada. O receptor dessa prova foi exclusivamente SQLite em memória; a primeira página persistente tinha0factos, sem criar entregas para alimentar o ensaio. Sessão passada por stdin privado ao filho oculto, sem argumento, ficheiro de credencial ou conteúdo secreto nas provas. Código de prova usa só a origem local fixa.
+
+Provas warehouse-http-source.json, warehouse-http-source-pg.json e warehouse-http-source-http.json; logs warehouse-http-source-regression.txt/warehouse-http-source-pg.txt. Sem build frontend, execução geral, revisão visual completa ou ensaio de certificados TLS de um host comercial. Nenhum schema de recepção, serviço permanente ou credencial técnica instalados em aplicações persistentes, e nenhum grant/conta/perfil alterados. SGF real mantém0.1.1 e a identidade adicional sem autorização. Preservar candidatas/trabalho paralelo.
+
+A lacuna histórica de transporte foi parcialmente resolvida com o cliente HTTP de sessão existente, não com API técnica permanente: faltam configuração administrativa da ligação no host, armazenamento/renovação protegidos, autenticação técnica restrita se necessária, agendamento e reconciliação/projecção autorizada dos vínculos no destino. Não apresentar ensaio local/cliente preparado como integração activa comercial, nem usar os factos recebidos para duplicar o motor de stock/facturação/contabilidade. A configuração comercial pela interface continua pendente neste recorte.
+
+## Integração funcional entre módulos do SGC — 02/11/2026
+
+O proprietário esclareceu que a integração desta etapa é entre os módulos do próprio SGC. Orientação registada em AGENTS.md; não é instalação/ligação ao SGF nem pedido para criar acessos externos. Este incremento usa directamente os motores e documentos internos, sem exportar/importar dados para ligar módulos do mesmo produto, criar chaves técnicas, novas tabelas, pessoas ou concessões.
+
+Implementado InternalSupplyTrace: a requisição ao armazém liga à necessidade de reposição, à requisição de compra canónica, à etapa humana pendente, às encomendas e aos recebimentos já associados. A compra oferece ligação de regresso à requisição ao armazém quando o utilizador tem competência para a consultar. Os caminhos existentes são reutilizados; nenhum formulário duplicado, novo motor ou documento empresarial paralelo foi introduzido. O destino, equipamento e origem preservados nos registos existentes continuam a ser a referência, sem reescrever decisões/versões anteriores.
+
+No detalhe do armazém, painel Reposição e compras associadas mostra artigo/unidade, saldo do pedido ainda sem reserva, referência da necessidade capturada e da situação actualmente registada para esse artigo/armazém. Mostra estados de compra, tarefa de aprovação pendente, encomendas e quantidades aceites efectivas neste destino, assinalando estornos pelo motor InventoryReversals existente. Uma entrada física não é apresentada como reserva/entrega já decidida; o autorizador elegível tem atalho para abrir o mesmo formulário de actualização de reserva, com as mesmas verificações e razão obrigatória. A impressão da requisição de saída não inclui o acompanhamento operacional adicional. O uso interno continua sem factura.
+
+A reposição é partilhada por artigo/armazém: a ligação não atribui uma compra exclusiva nem promete que uma entrada esteja livre, utilizável ou automaticamente afectada ao pedido. A qualidade, validade, disponibilidade, reserva, autorização e entrega mantêm WarehouseWorkdesk/WarehouseFulfilment/WarehouseReservations/StockWorkflow como autoridade. Uma compra rascunho ainda exige conferência/submissão e as aprovações vigentes; não foi criada uma passagem automática por etapas.
+
+A associação histórica usa a última versão da necessidade que efectivamente mencionou a requisição ao armazém; a situação actual da mesma necessidade é consultada separadamente. Uma compra futura do mesmo artigo, criada depois de desaparecer esta origem, não é retrospectivamente atribuída ao pedido antigo. SQL de selecção/grouping é aplicado na base, antes de materializar os registos, sem um limite global100 que esconda origens antigas. Implementações SQLite/PostgreSQL ensaiadas. Nada é associado por igualdade de nome ou por destino presumido.
+
+Permissões por módulo mantidas: compra só surge quando Requisition::visibleTo permite consultar o respectivo processo; caso contrário aparece indicação genérica de acesso restrito, sem IDs/números privados, valores ou campos financeiros. Ligações a encomendas respeitam os papéis de consulta desse percurso. WarehouseWorkdesk::canView centraliza o mesmo acesso ao detalhe de armazém, incluindo empresa, proprietário, departamento, competências e acumulação necessária à entrega. A ligação inversa não concede consulta de armazém a quem apenas tem Compras. O detalhe continua numa transacção de leitura coerente PostgreSQL, sem comandos/avisos novos por GET.
+
+Validação NOVA:52testes/668asserções na regressão dirigida (novo rastreio, piloto integrado, atendimento, compra automática e contexto);7testes/105asserções em PostgreSQL descartável no subconjunto novo+piloto. Não somar suites sobrepostas. Os6testes novos exercitam necessidade real a partir de pedido125 com stock120, criação de rascunho canónico, submissão e etapa humana ainda pendente, ligação de ida/volta, leitura sem alterar diários/aprovações/reservas/orçamento, ausência de permissão de compra, outra empresa e preservação da origem perante versões futuras sem esse pedido. O piloto integrado anterior foi executado nesta suite PostgreSQL: entrega parcial, entrada física em ordem já emitida, reforço de reserva e conclusão, sem aprovar o novo rascunho.
+
+Teste documental separado de projecção usa a ordem/recepção já emitidas do fixture (120unidades) e referência sintética da necessidade para verificar as quantidades mostradas; a classificação de estorno é fornecida pelo serviço simulado de InventoryReversals. Não representa adjudicação/emissão real do novo rascunho, associação manual disponível na interface ou novo ensaio completo do motor de estornos. O agregado PDO PostgreSQL foi normalizado apenas na asserção de quantidade da base, preservando a tipagem inteira do relatório.
+
+Tipos, lint/formatação, build, análise PHP dirigida e18hashes comuns conferidos. HTTP8005 confirmou a consulta do armazém, sem criar pedidos para produzir evidência:0detalhes existentes disponíveis nessa primeira página. Os detalhes/ligações foram exercitados com dados sintéticos apenas nas bases descartáveis. Sem revisão visual completa ou nova bateria geral. Provas internal-supply-integration.json, internal-supply-integration-pg.json e internal-supply-integration-http.json; logs internal-supply-integration-regression.txt, internal-supply-integration-pg.txt e internal-supply-integration-build.txt. Nenhuma migração, perfil, conta ou dado empresarial persistente alterados; SGF0.1.1 preservado. Este percurso de acompanhamento está ligado, mas não declara todas as funcionalidades/propostas ou a configuração comercial integralmente concluídas.
+
+## Tarefas integradas por perfil, equipa e área do SGC — 02/11/2026
+
+Implementado WorkflowTaskInbox e As minhas tarefas nos menus de Requisições (/requisicoes/tarefas) e Pessoas e contratações (/pessoas/tarefas). São projecções do trabalho actual, sem outra máquina de workflow, tabela de tarefas, cópia de documentos, credenciais ou outorga de competências. Compras/armazém e Pessoas mantêm rotas, filtros e contextos separados. As ligações levam aos formulários originais, que continuam a revalidar versão, pessoa, competência, independência, conflito e demais condições da intervenção.
+
+Cobertura deste incremento: rascunhos próprios que o utilizador pode conferir/submeter, etapas de requisição de compra em que Requisition::canDecide autoriza a decisão, decisões de armazém, reservas com falta ou conferência necessária, entregas e confirmações individuais de recepção. A lista de Pessoas reutiliza PeopleWorkdesk e PeopleAccess, incluindo a equipa, tipo de processo, intervenção e prazo efectivamente registados. Outras tarefas de consulta ao mercado, adjudicação, facturas/pagamentos e fases ainda por desenvolver continuam nos seus percursos próprios; esta lista não declara coberto todo o catálogo funcional da aplicação.
+
+O armazém reutiliza WarehouseWorkdesk::forView/progress/health: não atribui a decisão ao requisitante, não atribui entrega ao autorizador e exige as duas competências de entrega/armazém existentes. Uma reserva completa sem problema deixa de gerar tarefa de conferência; uma falta ou origem/qualidade a conferir mantém a atenção. Entrega impedida continua visível como bloqueada e abre o pedido para consulta, sem recomendar lançamento físico. Cada entrega não confirmada gera a sua própria ligação de recepção, mesmo após cancelar só o saldo não entregue. Identificadores HTML das confirmações passaram a incluir o evento, evitando âncoras repetidas em entregas parciais. A confirmação real elimina apenas a tarefa correspondente, sem outra saída ou factura.
+
+Administração de consulta e manutenção temporária não recebem competências operacionais por abrir a página. Perfil expirado, alteração de competência, âmbito departamental, empresa e contexto continuam a ser conferidos. Em Pessoas, atribuição global de função não substitui a equipa/autorização do processo; retirar a vigência da equipa retira a tarefa visível. Minutas continuam minutas e não foram tratadas como contratos formalizados/execução RH. Abrir a tarefa não marca um aviso como lido, não toma a decisão e não cria prazos, prioridades ou destinatários presumidos. Os avisos/resultados existentes permanecem no seu percurso de leitura.
+
+As consultas percorrem o âmbito autorizado em lotes de100, aplicam as verificações dos motores existentes e só depois filtram/paginam25tarefas. Não há corte global100 que faça desaparecer pendências antigas. Ordenação de apresentação pela data de origem, com desempate natural dos identificadores, sem definir prioridade de atendimento/alocação; ausência de data não recebe uma data fictícia. Contagens por módulo cobrem o âmbito inteiro e são separadas do resultado filtrado. Pesquisa aceita referência ou tarefa. A resposta é privada/no-store, com leitura coerente PostgreSQL; auditoria de consulta de manutenção já existente não é substituída. Não existem notificações externas, subscrições ou automatismo de decisão neste incremento.
+
+Validação NOVA:60testes/925asserções na regressão dirigida;15testes/362asserções no subconjunto PostgreSQL descartável (8novos, rastreio interno e piloto integrado). Suites sobrepostas, não somar. Os8testes novos percorrem rascunho/submissão/etapa humana, autorização/entregas parciais/cancelamento de saldo/confirmações individuais, reserva em falta e entrega impedida por origem alterada,30rascunhos com paginação/pesquisa e retenção do mais antigo, leitura sem alterar diários/decisões/stock, retirada de competência, equipas de Pessoas e abertura independente com a declaração de conflito exigida, manutenção, empresa e contexto. Actores adicionais e dados de ensaio apenas em bases descartáveis.
+
+Tipos, lint/formatação, build, análise PHP dirigida e18hashes comuns passaram. HTTP8005 confirmou as duas rotas por selecção do contexto autorizado, com a conta administrativa sintética existente e0tarefas em cada área; isso demonstra acesso/estrutura, não atribuição de tarefas operacionais ao administrador ou percurso visual com pendências. Os percursos com tarefas foram ensaiados nas bases de testes. Sem revisão visual completa, nova bateria geral, novas disputas concorrentes, migração, contas/perfis/grants ou dados empresariais persistentes. SGF real0.1.1 preservado e fora do âmbito pedido. Provas workflow-task-inbox.json, workflow-task-inbox-pg.json e workflow-task-inbox-http.json; logs workflow-task-inbox-tests.txt, workflow-task-inbox-regression.txt, workflow-task-inbox-pg.txt e workflow-task-inbox-build.txt.
+
+## 02/11/2026 — integração interna de facturas, pagamentos e tarefas financeiras
+
+InvoiceWorkflow reutiliza unidade, preço e IVA das linhas preservadas na encomenda quando o pedido traz apenas a referência da linha e a quantidade. Valores explicitamente enviados continuam sujeitos à coincidência exacta; quantidades continuam limitadas pela recepção aceite, devoluções, estornos e facturas já validadas. A interface apresenta esses dados sem os pedir novamente nem os transmitir como nova fonte. Número/data e totais do documento do fornecedor permanecem necessários à conferência independente. A validação mantém a reconstituição das linhas históricas, arredondamento acumulado, conflitos e consumo único no núcleo orçamental.
+
+As minhas tarefas inclui o módulo Finanças: facturas pendentes e registos de pagamento externo pendentes, apenas para conferente financeiro independente e actualmente elegível. InvoiceWorkflow::reviewerEligible e ExternalPaymentWorkflow::reviewerEligible são projecções dos mesmos requisitos funcionais; os comandos continuam a revalidar os factos dentro da transacção. A lista não decide, não lê avisos nem revela referências externas, evidências ou dados bancários. O catálogo de tarefas dos perfis explica esta intervenção dentro da competência financeira existente; não cria uma segunda concessão nem torna as subacções seleccionáveis de forma independente.
+
+Pagamentos importados continuam dependentes da activação própria e de pa_access/validate. Desactivação, conflito de origem ou ausência dessa competência retiram a tarefa sem converter o saldo para um cálculo paralelo. Uma nova revisão bancária não esconde a possibilidade de rejeitar fundamentadamente um registo pendente; a confirmação exige novamente a versão bancária aprovada, saldo, elegibilidade e evidência. Manutenção temporária pode consultar nos limites existentes, sem receber formulários de execução ou referências reservadas do pagamento e sem intervir nas decisões.
+
+As páginas /facturas e /pagamentos aceitam focos autorizados por documento; /facturas também por encomenda e /pagamentos por factura. As listas são paginadas com os filtros preservados. A factura validada abre os pagamentos da sua origem; o percurso da compra abre as facturas da encomenda e cada factura/registo externo concreto. Focos de outra empresa são recusados. O circuito reaproveita documentos existentes, sem transferências bancárias, nova dívida, nova saída interna, aprovação automática ou fusão com Pessoas/SGF.
+
+Verificações novas: 81 testes/800 asserções na regressão de FinanceIntegrationTest, PaymentIntegrationTest, InvoiceTest, ExternalPaymentTest, PurchaseProcessTest e WorkflowTaskInboxTest; 23 testes/357 asserções em PayablePaymentDecisionTest e InternalRequisitionCandidateTest. O teste antigo de activação do ensaio foi ajustado para reconhecer o PostgreSQL descartável explicitamente permitido; a ausência de concessões específicas continua a recusar todos os endpoints financeiros próprios. A versão corrigida desse teste passou também isoladamente em SQLite (1/6). Tipos, lint/formatação, build, análise PHP dirigida e 18 hashes comuns conferidos. HTTP8005 com Administração e Finanças sintéticos existentes confirmou facturas, pagamentos e tarefas financeiras: 1 factura visível, 0 pagamentos e 0 tarefas financeiras em cada perfil. A consulta de pagamentos conserva a auditoria de acesso existente. Os cenários com decisões e pendências ficaram nas bases descartáveis; não houve migração, criação de contas/perfis/grants ou decisão empresarial persistente. Não há revisão visual completa, nova bateria geral ou certificação comercial integral. A prova PostgreSQL é registada abaixo após conclusão; suites sobrepostas não devem ser somadas. SGF real 0.1.1 e trabalho paralelo preservados.
+
+Prova PostgreSQL concluída:83 testes/623 asserções aprovados, incluindo decisões importadas e a correcção da expectativa de activação; base descartável removida. A primeira tentativa excedeu120s; o modo financeiro passou a permitir300s. Nova execução identificou e corrigiu o pressuposto antigo de ambiente do teste; execução final integral aprovada em112,485s. Provas finance-integration.json, finance-integration-pg.json e finance-integration-http.json; logs finance-integration-tests.txt, finance-integration-authority-tests.txt e finance-integration-pg.txt. Não contar as tentativas interrompida/reprovada como aprovações nem somar suites sobrepostas.
+
+## 02/11/2026 — adjudicação e conferência da emissão na lista de tarefas
+
+WorkflowTaskInbox acrescenta decisões de adjudicação e conferência para emissão da ordem na categoria Compras. A elegibilidade da decisão vem de PurchaseWorkflow::canDecide, incluindo a etapa efectivamente pendente, departamento, independência, competências actuais e conflitos. A verificação do cancelamento pendente foi centralizada no mesmo motor; o congelamento deixa de ser apenas uma guarda do comando/controlador. A emissão usa PurchaseWorkflow::canIssue tanto na lista como no formulário e considera adjudicação aprovada, função de Compras, manutenção, âmbito de leitura, cancelamento, ordem já existente e conflitos de participação.
+
+A tarefa de emissão pede conferência: não garante que a matriz/proposta ainda permita emitir. O comando continua a revalidar a base, propostas válidas, regras preservadas, aprovações finais, entrega, investimento, admissão/recomendação e orçamento dentro da transacção. Proposta expirada após aprovação mantém a necessidade de conferência, mas impede criar a ordem. Emitir efectivamente retira a tarefa; a consulta não emite nem altera orçamento/histórico. A data de entrega não foi convertida em prazo de decisão, nem foi inventada prioridade ou atribuição exclusiva. Registar a selecção, decidir e emitir recusam manutenção também no serviço; o controlador usa explicitamente o utilizador interno autenticado.
+
+As descrições de tarefas dos perfis explicam as intervenções de adjudicação/emissão nas competências existentes. Sem nova concessão, etapa de aprovação, tabela, migração ou notificação persistente da adjudicação. O encaminhamento é uma projecção actual e conserva o percurso original. O perfil de consulta administrativo não recebe tarefas de execução. Uma atribuição expirada sem outra competência válida continua a impedir acesso à aplicação (403), por decisão do middleware existente; o teste foi corrigido para esperar esse bloqueio, sem o enfraquecer.
+
+Quatro novos testes percorrem a selecção real com três propostas e avaliações, três etapas finais (departamento/Compras/Finanças), emissão efectiva e desaparecimento da tarefa, cancelamento pendente e rejeição que retoma a etapa, consulta sem mudar adjudicação/orçamento/auditoria, manutenção/expiração e proposta expirada que impede emissão. PostgreSQL descartável:4 testes/217 asserções aprovados, base removida. HTTP8005 com os três perfis sintéticos já existentes confirmou a lista no contexto autorizado; cada um viu0tarefas de Compras e0tarefas de adjudicação. Isso não substitui os percursos com pendências das bases descartáveis, nem representa revisão visual. Tipos/lint/build/análise PHP e18hashes comuns conferidos; regressão integral dirigida registada após conclusão abaixo. Sem contas/perfis/grants, decisões empresariais ou instalação SGF persistentes. Continuam por ligar à lista as intervenções de consulta ao mercado, avaliação documental/formal e execução contratual; não declarar estas fases cobertas apenas pela ligação da adjudicação.
+
+Regressão final da adjudicação:98 testes/2070 asserções aprovados (PurchaseAwardOrderTest, WorkflowTaskInboxTest e InternalRequisitionCandidateTest). A primeira tentativa falhou somente na expectativa de acesso após expiração; o teste corrigido passou isoladamente1/32 e a suite integral foi repetida e aprovada. PostgreSQL4/217. Provas award-task-integration[-pg/-http].json e logs award-task-integration-tests.txt, award-task-integration-pg.txt, award-task-revocation-test.txt, award-task-integration-build.txt. Não somar suites financeiras/adjudicação sobrepostas nem declarar bateria geral reexecutada.
+
+## 02/11/2026 — consulta ao mercado e avaliações ligadas às tarefas
+
+QuotationParticipation centraliza a autoridade de preparação e avaliação anteriormente concentrada no controlador. Formulário, tarefas e comandos consultam as mesmas condições de empresa, departamento/visibilidade, estado aprovado, encerramento pela adjudicação, manutenção, independência do requisitante, conflitos, validade/elegibilidade do fornecedor e avaliação pessoal já registada. O comando recarrega a competência do interveniente depois do bloqueio da empresa, sem confiar num utilizador de sessão desactualizado. Cálculo de preços, descontos, IVA, mínimos, ponderações e avaliação mantêm os motores existentes. A comparação continua a decidir quais as avaliações qualificadas; perder competência não apaga nem substitui a avaliação histórica.
+
+As tarefas de Compras passam a distinguir propostas em falta face ao mínimo preservado, avaliações pessoais ainda possíveis enquanto o mínimo de conferentes qualificados está incompleto, impedimentos da comparação e resultado pronto para conferência dos requisitos de adjudicação. Mínimos não são fixados novamente nem atribuídos por pessoa. Avaliação opcional continua possível no formulário após satisfação do mínimo, sem ser apresentada como pendência obrigatória. O resultado pronto encaminha para o formulário original, sem prometer que todos os requisitos formais de adjudicação estejam preenchidos ou efectuar selecção. Uma adjudicação existente fecha a consulta mesmo se rejeitada; não reabre etapas nem apaga histórico. Não são criados avisos persistentes, decisões automáticas, alçadas ou prazos novos.
+
+Ligações quotation_id abrem a proposta concreta e prepare=1 abre a preparação existente, reutilizando as linhas da requisição; origem inválida/outra requisição recusada. Formulários usam elegibilidade por proposta no servidor, e a preparação desaparece se a competência se perder. História Inertia protegida e resposta privada sem cache. A lista é ordenada/paginada no âmbito completo e conserva consultas antigas.
+
+Provas novas:169 testes/2924 asserções na regressão dirigida de SupplierQuotationTest, PurchaseAwardOrderTest, WorkflowTaskInboxTest, ConflictDeclarationTest, FinanceIntegrationTest e PaymentIntegrationTest. PostgreSQL descartável11/353 (inclui passagem real da comparação à adjudicação e encerramento), base removida. A primeira tentativa dirigida revelou um pressuposto errado da fixture: a mudança de função tinha sido ignorada pelo preenchimento permitido do modelo. O teste passou a fazer a mudança explícita e a suite integral foi aprovada. Inclui mínimos preservados mesmo após alteração da configuração geral, tarefas exactas, expiração, conflitos declarados, perda de competência, manutenção, departamento/empresa,30consultas/paginação/pesquisa, leitura sem efeito e avaliações históricas preservadas. Tipos/lint/build/análise PHP/18hashes passaram. HTTP8005 com os três perfis existentes viu0tarefas de consulta em cada perfil; pendências ensaiadas nas bases descartáveis. Provas quotation-task-integration[-pg/-http].json e logs quotation-task-integration-tests.txt, quotation-task-integration-pg.txt e quotation-task-integration-build.txt. Sem migração/contas/grants/decisões persistentes, revisão visual completa, bateria geral ou alteração SGF0.1.1. Suites sobrepostas não se somam.
+
+## 02/11/2026 — diagnóstico da activação dos perfis e ligação às tarefas
+
+Administração → Parametrização mostra por pessoa as competências actuais e dos perfis guardados, acrescentadas/retiradas, preparação implícita de requisição no modo antigo, contextos efectivamente contratados/configurados/funcionais, manutenção, tarefas do catálogo e administração permanente. É uma consulta, não concede acessos nem confirma decisões. OrganizationActivationPreview reutiliza configuredPermissions/configuredMaintenanceOnly de OrganizationAccess, configuredAvailable de WorkContext e permanentAdministrators de OrganizationConfiguration; estas extracções também são usadas no percurso efectivo, evitando outra interpretação de concessões. As regras existentes de activação continuam: confirmação explícita, versão actual, dois administradores permanentes e regras não provisórias. Não é imposta uma condição nova de atribuir todos os utilizadores, nem se activam módulos pendentes por constarem dos contextos.
+
+A consulta administrativa usa transacção de leitura consistente, privada e com história protegida. Selecção de contexto, competências específicas do financeiro, segregação por processo e limitações de manutenção continuam a aplicar-se depois da configuração; o catálogo explica tarefas potenciais, não decisões já atribuídas em processos. Provas:6/101 nos testes novos; regressão dirigida89/1394 incluindo configuração, contextos, cotações, tarefas e integração financeira; PostgreSQL19/215 com os testes novos e WorkContextTest, base removida. Tipos/lint/build/análise PHP/18hashes passaram. Provas organization-activation-preview[-pg/-http].json e logs organization-preview-tests.txt, organization-preview-regression.txt, organization-preview-pg.txt, organization-preview-build.txt. Não somar suites sobrepostas, nem declarar bateria geral/revisão visual novas.
+
+O diagnóstico HTTP8005 encontrou12contas sintéticas, configuração inactiva versão1,0perfis/atribuições,0administradores permanentes configurados e regras provisórias. A projecção dos perfis vazios retiraria acesso aos12; a activação directa já é impedida pela regra existente de dois administradores. Nenhum perfil/atribuição/regra foi alterado nesta avaliação.
+
+## Proposta concreta pendente de autorização — perfis do piloto sintético local
+
+Destino exclusivo: SGC em http://127.0.0.1:8005,12contas sintéticas já cadastradas, nenhuma conta nova. Preparada a partir do diagnóstico de02/11/2026 às17:20:38UTC, versão1; origem e hash em docs/proofs/pilot-access-proposal.json. O ficheiro é proposta pendente, não instalador nem configuração por defeito de clientes. scripts/prepare-pilot-access-proposal.php só lê o diagnóstico e escreve a proposta; não inicia a aplicação nem liga à base.
+
+Preservar as funções e os contextos actuais das12contas, tornando explícita nos perfis a competência requester que actualmente é implícita no modo antigo. Criar seis perfis base de compatibilidade e cinco perfis de tarefas do piloto (11no total), com atribuições permanentes somente às contas sintéticas existentes. Manter a administração actual das contas6 («Obrigações — consulta») e8 («Proprietário — avaliação local»); não conceder administração a outras contas. Preservar separadamente pa_access, identidades, documentos, aprovações, regras capturadas nos processos e vínculos actuais. As opções de terceiros já existentes conservam o seu estado funcional limitado; a proposta não implementa nem abre os percursos transferidos ao SGF.
+
+| Conta existente | Tarefa adicional proposta | Competência funcional |
+| --- | --- | --- |
+| 9 — Solicitante — avaliação local | Preparar/submeter requisição interna e confirmar recepção própria | warehouse_request_prepare |
+| 10 — Chefe de departamento — avaliação local | Autorizar/recusar requisição interna do departamento e acompanhar reservas | warehouse_request_review |
+| 13 — Recepção / Armazém — avaliação local | Confirmar entrega física, independente do requisitante e autorizador | warehouse_request_deliver, com warehouse já existente |
+| 11 — Procurement — avaliação local | Preparar políticas e parametrização da reposição | supply_prepare |
+| 12 — Finanças — avaliação local | Conferir a parametrização preparada por outra pessoa | supply_review |
+
+Confirmar para este piloto sintético as alçadas actualmente apresentadas (25.000,00MZN e150.000,00MZN, IVA incluído), sem mudar os seus valores, passando-as de provisórias a expressamente configuradas. Preservar o IVA predefinido16% e os mínimos/ponderações de comparação existentes. Activar os perfis depois da sua conferência pelos motores existentes e registar todos os comandos/versionamento na auditoria. Não publicar artigos, criar movimentos, definir quantidades mínimas/reposição, executar compras/aprovações ou alterar o SGF por esta autorização. Responsável/centro de custo de futuras regras automáticas continuam na parametrização específica de cada artigo/armazém.
+
+A proposta está a ser validada apenas por reprodução descartável: criação dos perfis/atribuições, confirmação das mesmas alçadas e contextos, diagnóstico e activação explícita no ensaio, comparação antes/depois de funções, preparação, departamento e contextos dos12utilizadores. A aplicação no ambiente8005 depende de autorização expressa para esta substituição da autoridade de acesso e confirmação das alçadas, conforme AGENTS.md, Administração e entrega. O desenvolvimento de código e os ensaios já autorizados prosseguiram sem esse efeito persistente.
+
+Ensaio final da proposta aprovado:1 teste/156 asserções em SQLite em memória e1/156 em PostgreSQL descartável, base removida. Executou os comandos reais de criação de11perfis,12atribuições, regras/contextos e activação apenas na simulação. Confirmou12utilizadores, nenhuma perda de competências/contextos/preparação/âmbito departamental, dois administradores permanentes, acréscimos exactamente iguais aos cinco propostos e nenhum processo/ordem/factura/grant pa_access criado. Provas pilot-access-proposal-dry-run.json e pilot-access-proposal-pg.json; logs pilot-access-proposal-tests.txt e pilot-access-proposal-pg.txt. A configuração8005 permanece inactiva e a proposta pendente de autorização; o ensaio não é autorização nem aplicação. Resumos das integrações em quotation-task-integration.json e organization-activation-preview.json.
+
+## 02/11/2026 — devolução nativa de excedente, isolada
+
+Implementado NATIVE-WAREHOUSE-UNUSED-RETURN-SIM-v1: entrega interna autorizada/recebida → declaração individual de não utilização pelo destinatário original → recepção física por armazém independente. Prévia, confirmação, retirada e recusa, versões/contextos/permissões revalidados, decisões append-only, auditoria e escritor único inventory-return 0.1.0. Reutiliza AutomaticPurchases para sinalizar uma vez dentro da transacção; não aprova, encomenda ou altera políticas. Interface e histórico paginado com leitura conjunta de preparadores. Direitos warehouse_return_read/prepare/confirm apenas configuráveis e atribuídos a QA isolada; feature desactivada por defeito, sem grants/activação no piloto ou reais.
+
+Provas consolidadas 36 testes distintos/544 asserções (19 nativos/440, reversões afectadas16/81, observador SGF1/23), sem somar repetições: recepção10, entrega5, devolução3, saldo8 e três movimentos. Bloqueio real da empresa observado: repetição após libertação não duplica; disputa por capacidade409; HTTP423 sem efeitos e recuperação explícita; falha transaccional/artefacto503 recuperam; retirada/confirmação mutuamente exclusivas. Tipos, PHPStan dirigido, bundle e renderização do histórico passaram. Build privado qualificado passou sem React Compiler opcional/fontes remotas; não valida build optimizado predefinido ou revisão visual.
+
+Ver docs/warehouse-unused-return.md e C:/laragon/www/sgf-tempo/docs/NATIVE_WAREHOUSE_RETURN_DELIVERY.md, com relatório/hash de provas. O SGC funciona sem ligação SGF; observação SGF foi só QA e não composição operacional instalada. A previsão existente mantém saídas brutas: devolução muda saldo/hash, não deduz da média. Consumo líquido temporal é evolução separada. Preservados dados/demos/acessos/cache/seis associações/checkpoints/trabalho alheio. Sem publicação, checkpoint, activação de candidatas ou uso real.
+
+## 02/11/2026 — AUTORIZADO E APLICADO: perfis do piloto sintético local
+
+O proprietário respondeu «autorizo» à pergunta de activação da distribuição concreta acima, exclusivamente na avaliação SGC8005. Autorização registada em docs/proofs/pilot-access-authorization.json, vinculada ao hashff45883f1a0e1e3ab228f8bc4e6c03a66e9a5953b04af44b2e26cd6b74b60a44. A proposta original não foi reescrita: o seu estado pendente é histórico; a autorização/aplicação actuais têm provas próprias. Esta entrada substitui a pendência de autorização anterior para este âmbito exacto.
+
+scripts/apply-pilot-access-proposal.php conferiu a identidade do destino local, o hash da proposta ensaiada, a autorização específica, versão1,12utilizadores e respectivas identidades/funções/departamentos/contextos, ausência de perfis/atribuições e as mesmas alçadas. Preflight passou sem alterar dados. A aplicação usou OrganizationConfiguration e os resolvedores efectivos, com o administrador existente8;11perfis,12comandos de atribuição (17vínculos), confirmação das mesmas regras/contextos e activação foram executados numa transacção consistente, sob bloqueio da empresa. Qualquer divergência anterior ao commit cancelaria a transacção. Não houve migração, alteração de código do núcleo comum, criação de contas ou decisão operacional.
+
+Às17:52:30UTC de02/11/2026 a configuração foi confirmada na versão27. Mantidos os administradores6/8 e as tarefas adicionais autorizadas9/10/13/11/12. As competências/contextos, preparação de compra e âmbito departamental dos12utilizadores foram comparados antes/depois e preservados, com acréscimos exactamente iguais aos da proposta. As alçadas25.000/150.000MZN comIVA passaram a explicitamente configuradas para estes ensaios, sem alterar os valores ou IVA16%. Requisições, aprovações, orçamentos, documentos, stock e concessões financeiras pa_access foram preservados.
+
+Cópia privada guardada em C:/laragon/data/sgf-tempo/backups/compras-pilot-access-20261102-175214-d26e4c/before.dump, SHA-256afa964b52ea6c552466360a2ebc2523126f37ccf0a3aecd21f822a0767d35d75. pg_dump usou o snapshot exportado da mesma transacção; restauro de controlo descartável conferiu112tabelas antes de aplicar e foi removido. Após os comandos,107tabelas fora da configuração/auditoria/Tenant coincidiram integralmente com a origem; os restantes campos de Tenant também foram conferidos, permitindo apenas rules_provisional, work_context_policy e updated_at. Prova pilot-access-activation.json e log pilot-access-activation.txt. Sintaxe, formatação do aplicador e18hashes comuns conferidos. As provas anteriores de testes descartáveis não são declaradas reexecutadas por esta aplicação.
+
+A autorização não publica artigos nem define mínimos, quantidades, políticas ou regras automáticas de artigo/armazém. Essas parametrizações permanecem no seu percurso próprio, com conferência independente; não foram criados movimentos, requisições, encomendas, facturas ou pagamentos por esta aplicação. SGF real0.1.1 e a proposta não autorizada de identidade adicional nesse ambiente permanecem fora do âmbito. A verificação HTTP posterior dos cinco perfis e do diagnóstico é registada abaixo após conclusão; nenhuma revisão visual completa foi declarada.
+
+Verificação posterior concluída por HTTP em8005: configuração activa27,11perfis,17vínculos,12contas, dois administradores permanentes e nenhuma perda de competências/contextos/preparação/departamento. Os cinco perfis sintéticos abriram os seus percursos: requisição, revisão do departamento, entrega, preparação da reposição e conferência da reposição. Procurement não recebeu conferência; Finanças não recebeu preparação; chefe/armazém não receberam preparação de requisição interna. Todos os cinco foram impedidos de entrar na administração de perfis. Cada perfil viu0tarefas na situação actual, e a consulta de tarefas de Pessoas continuou disponível à administração no seu contexto próprio. Sem comandos de negócio nesta verificação. Prova pilot-access-activation-http.json e log pilot-access-activation-http.txt; sem revisão visual completa.
+
+Diagnóstico efectivo do piloto: intervenientes independentes prontos,1armazém,0artigos publicados com origem actual,0políticas internas aprovadas e0regras automáticas activas. Serviço periódico e recuperação técnica recentes/sem pendências. Warehouse/replenishment_scenario_ready continuamfalse pela ausência de catálogo/políticas; client_configuration_complete também permanecefalse porque o diagnóstico exige contexto interno explicitamente contratado, além dos perfis/administração, enquanto a avaliação conserva o âmbito sintético existente. Não alterar direitos comerciais por inferência nem declarar entrega de cliente. Próxima preparação funcional: catálogo e políticas/regras específicas por artigo/armazém, com responsáveis/centros e quantidades escolhidos na parametrização e conferidos independentemente. Esta autorização de perfis não fixa esses valores.
diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md
index c29933f..539c691 100644
--- a/docs/orientacoes-continuidade.md
+++ b/docs/orientacoes-continuidade.md
@@ -1,5 +1,25 @@
# Continuidade do desenvolvimento do SGC
+**Último estado — sourcing preparado, demonstração parcial:** candidata **040fb0cb67a77d61**, manifesto `040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58`, [entrega/provas/recuperação](consultas-adjudicacao-sgf.md). Em 8039/55439/1:1:1 há fornecedores, três cotações e um avaliador por cotação; o fluxo completo foi confirmado **só nas cópias isoladas**. Proprietário recusou quarta conta e autorizou exactamente prepare ao ID 1, evaluate/propose ao ID 2; não conceder confirm, aprovação ao requerente ou escrita ao Consulta para completar o cenário. Requisição 1/100 MZN/versão 3 e reserva única intactas. Não declarar integração integral ou adjudicação concluída na demonstração.
+
+Schema sourcing R1 é extensão auditada do ledger base R2. Código e schema/dados recuperam-se separadamente; simples rollback ao 166 não permite reabrir com o catálogo alterado. Backup privado antes da extensão em `private/sourcing-recovery/before.dump`, hash `cb9d55db5fee08cb36e3af4c962556b465be9e14071b5c3505c834d2ef4d7bcd`; journals nativos e prova de restauro das cópias preservados. Não usar down/truncate na demonstração nem executar plano SQL real. Valores diferentes da reserva e níveis superiores de aprovação estão bloqueados/sinalizados, não contornados. Implantação real/Tempo mantêm 0.1.1. Encomendas/recepção/pagamentos/contactos externos e revisão visual continuam separados; não repetir browser bloqueado ou suites aceites sem mudança relevante.
+
+**Último estado — leitura Consulta corrigida e primeiro percurso fechado em 8039:** sucessora `OPERATIONAL-CANDIDATE-166e397e3ca63f02`, ZIP SHA-256 `620660d76329c800f3c6b18a521c03ccdadce09502d2a7744e1e91b77560f785`, manifesto `166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de`. [Matriz/provas](transferencia-sgf.md). Não conceder direitos adicionais para leitura; Port::authorize permite somente view em registo já isolado. Requisição 1/100 MZN/versão 3 e reserva única preservadas; actualização→reversão→reaplicação conferidas, sem schema novo. A limitação 404 descrita historicamente abaixo foi resolvida. Próximo percurso escolhido: da requisição aprovada até cotações/comparação/adjudicação independente; mapear antes de implementar. Não declarar integração integral. Implantação real permanece adiada em 0.1.1, revisão visual separada. Recuperação desta correcção em `private/read-correction-recovery`; ID final de aplicação `3afbba5e3ed60b853d62b869`. Nenhum novo direito, conta ou DCL SQL.
+
+**Último estado — demonstração autorizada e habilitada:** 8039/55439/base sgf_modules_dev_2576427d1939/contexto 1/1/1. Aplicados exactamente os 14 direitos aprovados; centro/dotação 10.000 MZN/2026 sintéticos; percurso e reserva única conferidos, independente ID 2 aprova preparação ID 1. [Entrega, provas e recuperação](demonstracao-requisicoes-8039.md). Não reexecutar bootstrap (fechado) nem provisionadores destrutivos. Nenhuma conta ou DCL SQL criada; preservada instalação real em 0.1.1. **Pendência concreta:** perfil Consulta tem view mas regra actual oculta requisições de terceiros (404); decidir âmbito de leitura antes de alterar regra/direitos, sem conceder aprovação/configuração como contorno. Sessão desta cópia agora file; owner/runtime mantido como limitação de demonstração. Revisão visual e implantação real continuam separadas. Registos de proposta pendente abaixo são históricos, sucedidos pela confirmação e execução exclusivamente sintéticas.
+
+**Última orientação: não avançar com a instalação desactivada no destino real.** Próximo passo é a confirmação da [proposta única de ambiente sintético](proposta-desenvolvimento-sgf.md), reutilizando a instância existente 8039/55439, não criar outra. Nenhuma alteração funcional/SQL autorizada por esta proposta; não executar GRANT/REVOKE do plano real. Preservar `sgf_tempo_dev`, configuração Tempo e 0.1.1. Os registos de implantação real abaixo ficam adiados; valores sintéticos e contas existentes estão discriminados na proposta, sem obrigação de segundo administrador para esta demonstração.
+
+**Estado actual — candidata corrigida, 02/11/2026:** os bloqueantes históricos de duas tabelas, preparação de centros/dotações e separação SQL foram resolvidos/ensaiados na candidata `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`. [Decisão actualizada](decisao-instalacao-sgf.md) e [prova de 157 controlos/restauro de 103 tabelas](proofs/sgf-corrected-delivery.json) prevalecem sobre o estado anterior abaixo. **Não instalar, activar nem executar GRANT/REVOKE sem confirmação separada**; SGF mantém 0.1.1. Proposta SQL concreta em `docs/proofs/sgf-corrected-sql-plan.json`, nunca aplicada no destino. Backup/restauro de dados reais e configuração nominativa continuam por realizar na janela autorizada. Não repetir suites aceites nem navegador bloqueado. Ver também o registo detalhado no final deste documento.
+
+**Decisão de instalação sem execução — 02/11/2026:** proprietário escolheu decidir, não instalar. [Decisão](decisao-instalacao-sgf.md) e [inventário read-only](proofs/sgf-installation-decision-inventory.json): destino C:/laragon/www/sgf-tempo, PostgreSQL sgf_tempo_dev/127.0.0.1:55432, PHP 8.4.27, 87 tabelas, runtime sgf_dev_app sem DDL, cb/ir ausentes. Candidata 5fae7cf7f99c42ba/ZIP/hashes e ambos os plans conferidos sem aplicar. Não recomendar percurso operacional antes de fechar ligação migradora/DCL mínima e bootstrap de centros/dotações: interface selecciona fontes existentes e BudgetWorkspace depende de duas tabelas não entregues nesse schema. Catálogo funcional pronto em candidata não equivale a direitos runtime/admin já configurados; equipamento/peças não bloqueiam o recorte básico. Há só uma concessão administrativa da empresa, pendente regra de dois na entrega/activação. Navegação modular tecnicamente aceite em 02/11, separada de pendências visuais/associações/volume e da instalação de Compras. Sem repetir suites, criar backup, migrar, outorgar, parar/reiniciar serviço ou activar. Manter SGF 0.1.1. A decisão não autoriza executar a operação antes de confirmação nem ocultar as lacunas com fixtures/SQL de negócio.
+
+**Reconciliação da entrega modular — 02/11/2026:** documentos próprios do SGF agora conferidos registam entrega técnica de navegação modular e conclusão de regressão/perfis (185/3.220, focados 8/90, três perfis sintéticos), com recheck de integridade datado de 02/11. As referências históricas seguintes a «sem resultado apresentado» ficam sucedidas por esta evidência e pela matriz: não reimplementar nem repetir suites por causa delas. Revisão visual, recargas, associações antigas e volume/paginação continuam separados. Esta retoma fez leitura/reconciliação documental, sem nova execução dos resultados reportados. Candidata de requisições não instalada, SGF permanece 0.1.1. Instalação continua a exigir decisão separada; esta reconciliação não a presume.
+
+**Transição de bundles/caches nativos — 02/11/2026:** candidata `OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba`, ZIP e hashes em [proofs/sgf-update-cache-delivery.json](proofs/sgf-update-cache-delivery.json). 32 controlos novos aprovados, sem suites anteriores repetidas: actualização com proveniência, arquivo/reposição do bundle anterior, falha/recuperação, ordem de reversão, nova tentativa com histórico, Artisan config/route/event cache nativos, compilação interrompida e cache inválido, CLI e reinícios efectivos de servidor isolado. `current.json` privado da implantação não é o ponteiro purchasing, que continua 0.1.1. Não adoptar bundle desconhecido nem migrar schema por arrasto. Acção cache exige manutenção/--offline; após rollback, voltar a compilar para a versão reposta e reiniciar os serviços próprios. Caches personalizados e gestores de serviços de cada cliente continuam a exigir procedimento compatível. Nenhuma aplicação no destino/publicação/concessão real. Próxima acção: revisão e decisão separada de instalação, não nova autorização por etapa técnica nem activação presumida. Reorganização modular permanece distinta e pendente.
+
+**Aplicação/reversão do código — 02/11/2026:** `CodeDeployment` e `sgf-code-deploy.php` preparados para primeira instalação com manutenção/serviços parados, journal/backups, lock, verificação de hashes/baseline e restauro de código/caches padrão. 42 controlos novos aprovados via PHP fixado, sem DB/serviços/browser/suites antigas; [prova](proofs/sgf-code-deploy-delivery.json). Candidata `OPERATIONAL-CANDIDATE-10dce6cd57fa1f31`. Não activa configuração nem retira manutenção/reinicia serviços. Recusa caches personalizados e recuperação sobre alterações locais/caches posteriores. Não implementa ainda transição sobre bundle existente: não inferir updates integrais do novo percurso. Preservados SGF de destino 0.1.1 e artefactos anteriores. Próximo incremento: transição versionada e caches nativos/gestão de serviços em isolamento; nenhuma instalação ou activação no destino sem decisão separada. Reorganização modular permanece distinta e pendente.
+
**Arranque e HTTP SGF — 02/11/2026:** candidata `OPERATIONAL-CANDIDATE-4b25f81932efd69d` integra bundle/rotas pelo provider nativo e associa `purchasing_requisitions.view` à descoberta de Compras. Novo middleware mantém contexto/opção/âmbito/schema, módulo adquirido/seleccionado e geração de contexto; autoridade inicial administrativa permanece sem outorga automática. 27 controlos novos HTTP aprovados em instalação com código e dependências copiados, APP_ENV production, base/role próprias removidas; sem repetir suites aceites ou usar browser. [Prova](proofs/sgf-composition-delivery.json). Nenhuma aplicação no destino, ponteiro 0.1.1 e candidatas antigas preservados. Pendência seguinte: aplicação atómica/reversão das sobreposições/código e caches/reinício, antes de propor activação. Não declarar instalação comercial concluída nem confundir esta integração com reorganização modular posterior, que mantém entrega própria pendente.
**Composição operacional/preparação de schema — 01/11/2026:** candidata `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`, sem instalação no SGF. `scripts/prepare-sgf-operational.php` prepara artefacto; `scripts/sgf-internal-install.php` distingue plan read-only e migrate transaccional de schema, com destino/manifesto/backup conferidos, contrato orçamental e journal imutável. Mesmo motor e permissões, novo ramo operacional por destino/schema/âmbito explicitamente habilitados, sem obrigar nome de base QA ou ambiente de ensaio. 20 controlos novos + restauro em production/base não-QA aprovados em [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json), sem repetir suites aceites. Não confundir preparação de schema com activação: não alterou código/provider/rotas/concessões/dados no SGF instalado; ponteiro 0.1.1 e candidatas anteriores preservados. Próximo passo é composição de arranque/rotas e prova HTTP numa cópia isolada. Não executar migrate no destino, activar 0.1.2/outra versão ou publicar sem a decisão separada. Reorganização modular mantém-se distinta e sem resultado próprio apresentado.
@@ -533,3 +553,112 @@ Próxima acção: diagnosticar elegibilidade de encaminhamento para cada etapa a
**Autorização/entrega interna sem factura (01/11/2026):** migração48 eventos instalada só8005 com backup/restauro108 tabelas preservadas. /stock/requisicoes/{id} imprime requisição e entregas; submit, authorize/reject, deliver parcial/integral e receive. Tarefas review departamental independente e deliver+warehouse semgrants; requisitante não aprova/entrega, aprovador não entrega. Parcial aceite na submissão e autorizado. Cada entrega chama StockWorkflow issue; evento/auditoria/movimentos atómicos, repetição/versão controladas; recepção não movimenta stock.85testes/495asserções e PG12/122+2disputas reais passaram; geral a concluir. Reserva exclusiva e compra automática ainda NÃO implementadas, UI explícita. Não declarar CA01–09/totalidade proposta. Cancelamento, versão, delegação, avisos novos e destinatário alternativo pendentes. proofs/warehouse-fulfilment-install.json.
**Verificação final da autorização/entrega:** bateria geral908 testes/906 passaram/2ignorados/9508 asserções, sem falhas. PostgreSQL12/122 e2disputas reais já descritos; prova consolidada docs/proofs/warehouse-fulfilment.json. O acesso operacional exige atribuição explícita das tarefas warehouse_request_prepare/review/deliver e, para entregar, warehouse. Sem atribuições automáticas. Não se confundem estes resultados com reserva exclusiva ou geração automática de compras, que continuam pendentes.
+
+**02/11/2026 — Reserva e reposição parametrizada concluídas neste incremento:** migração49 instalada só na avaliação8005, diário warehouse_reservations imutável;109 tabelas preservadas por backup/restauro conferidos. Autorização reserva stock utilizável livre; entrega consome a reserva e o StockWorkflow atomicamente; revisor pode actualizar reserva ou cancelar apenas o saldo. Saídas, transferências, correcções, devoluções/abates e estornos protegem os compromissos. Retenção/validade pode criar défice, que impede a entrega; não remover controles de qualidade para satisfazer a reserva. Autorizações antigas precisam de actualização explícita da reserva.
+
+/stock/reposicao no menu Armazém e activos: supply_prepare prepara regra por artigo/armazém, supply_review activa/suspende com independência de todos os preparadores. Requisitante e centro são parametrizados conforme autorização expressa do proprietário, sem pessoas/valores fictícios. Política/catálogo/acordo/tributação reutilizados; peças exigem equipamento compatível. Gera só rascunho canónico, com executor real auditado e requisitante designado, sem impersonação, submissão, aprovação, orçamento, encomenda ou factura automática. Origem/local/equipamento visíveis na requisição gerada. Necessidade pendente quando falta configuração, fonte válida, destino ou estimativa. Reposição ligada em curso evita duplicação; rejeição não se recria pela mesma regra. Preservar as aprovações e o motor comum; não voltar a tratar compromisso físico como reserva planeada.
+
+Provas: geral920/918 passaram/2 antigos ignorados/9620 asserções; PG24/232 mais3disputas reais (incluindo activação concorrente com um rascunho); tipos/lint/build/análise PHP/18 hashes e HTTP de consulta passaram. docs/proofs/warehouse-replenishment.json e warehouse-reservations-install.json. A porta8005 estava desligada e foi retomada com a configuração/base existentes. Sem dados sintéticos/grants e sem prova visual por capturas. Estes resultados substituem a pendência histórica de reserva/compra automática; não significam que toda a proposta esteja concluída.
+
+Próximos incrementos concretos: avisos/tarefas do próprio armazém; fila durável e recuperação de sinal após interrupção, com varrimento de validade/mínimo sem evento; prioridades e prazos de expiração parametrizados; reserva após nova entrada continua a exigir intervenção explícita do revisor. Não introduzir prioridades, datas ou destinatários presumidos. Vendas e facturação continuam reservadas à composição SGF.
+# Continuidade — candidata SGF corrigida em 02/11/2026
+
+**Não instalar nem activar por continuidade implícita.** Proprietário autorizou apenas preparação isolada; SGF destino `C:\laragon\www\sgf-tempo`, base `sgf_tempo_dev` em `127.0.0.1:55432`, mantém pacote 0.1.1. Candidata actual `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`, ZIP SHA-256 `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703`; [decisão actualizada](decisao-instalacao-sgf.md) prevalece sobre os bloqueantes históricos.
+
+Resolvidos na candidata: duas tabelas UI (migração revisão 2, 16 novas), preparação de centros/dotações por API/interface e catálogo administrativo/frontend. 157 controlos aprovados numa reprodução de estrutura actual com roles distintas; recuperação de 103 tabelas conferida. Não repetir suites aceites sem motivo novo. Proposta SQL em `docs/proofs/sgf-corrected-sql-plan.json` exige confirmação antes de qualquer GRANT/REVOKE real; instalador nunca a executa. Usar ligação migradora temporária, runtime sem CREATE/ownership; não colocar segredos no bundle/.env persistente.
+
+Próximo passo: decisão explícita de instalação técnica desactivada com pacote/hashes e SQL concretos. Na janela aprovada, backup/restauro dos dados reais em base nova, planos reconferidos, migração e código/caches com procedimentos de recuperação distintos; não usar `sgf.ps1 stop` (pára cluster partilhado). Configuração real/dotações e dois administradores continuam requisitos para activação; fixtures nunca vão para cliente. Sem publicação, alteração de direitos públicos funcionais ou contratação para terceiros. Preservar alterações locais em curso e a aceitação própria da navegação modular.
+
+
+
+### 02/11/2026 ? candidata SGF utiliz?vel, ainda n?o instalada
+
+Conclu?das depend?ncias or?amentais de interface, administra??o pelo cat?logo nativo e prepara??o de centros/dota??es. Candidata 401ce31ae5f2dd20, schema revis?o 2; 157 controlos integrados + 17 React/render/assets + 54 deployment. Restauro efectivo de 103 tabelas e actualiza??o/recupera??o de c?digo sobre dados sint?ticos verificados. Ver docs/transferencia-sgf.md e a proposta revista no SGF. N?o executar a proposta antiga 5FAE. SGF continua 0.1.1, sem tocar em sgf_tempo_dev ou conceder direitos; instala??o/configura??o e mapa SQL s? para decis?o posterior. Preservar trabalho paralelo; equipamento/pe?as fora, revis?o visual/associa??es antigas/volume-pagina??o pendentes.
+
+**Avaliação geral de02/11/2026:** base funcional ampla, em consolidação para piloto/configuração e posterior entrega comercial. Revalidados33 testes/314 asserções,18 hashes e HTTP de reposição/avisos/relatórios de Pessoas/CSV. A geral918 aprovados é evidência anterior, não repetida nesta avaliação. Prioridade: recuperação durável da reposição após falhas e varrimento agendado; depois paginação das listas actualmente limitadas a100, avaliação de volume e parametrização das etapas ainda fixas. Completar tarefas/avisos de armazém e aceitação por perfis/impressão. Sem revisão visual completa ou certificação de produção. Não confundir candidata/demonstração SGF com instalação real, nem declarar a proposta integralmente concluída.
+
+**Consolidação para piloto — reposição,02/11/2026:** prioridades1–3 implementadas. Migração50 replenishment_work instalada só8005, backup/restauro110tabelas preservadas, sem contas/grants. Sinal durável atómico com alteração física; rascunho/origem/checkpoint atómicos; recuperação após interrupção e dois trabalhadores concorrentes não duplicam compra. ReplenishmentRun restringe execução a CLI/empresa/trabalho/SGC, sem login ou conta sintética; auditoria identifica execução periódica e atribuição do responsável de origem. Revalida regra/requisitante/centro/contexto/destino e mantém todas as aprovações.
+
+sgc:replenishment-tick descobre origens existentes e reavalia trabalhos vencidos; agendador por minuto, intervalo técnico persistido por trabalho por defeito5minutos. Validade/mínimo não dependem de novo movimento. Auxiliar scripts/replenishment-evaluation.php ligado à base de avaliação existente e observado em passagens sucessivas, integrado no arranque dev; não instalou serviço de arranque Windows nem alterou produção. Depois de reinício, retomar desenvolvimento/auxiliar; não provisionar outra base.
+
+/stock/reposicao: paginação independente de regras/necessidades/histórico/trabalhos interrompidos, filtro, pendências primeiro, indicação da última passagem conhecida e recuperação explícita só para supply_prepare/review. Actualizar não cria aprovações; admin de consulta não recupera. Sem revisão visual completa. Acompanhamento adicional das reservas e validação com utilizadores existentes são a fase seguinte; roteiro preparado em desenvolvimento.md, ainda não executado por pessoas reais.
+
+Provas NOVAS: regressão198/1579, final reposição18/155, PG33/310+4disputas, tipos/lint/build/análise/18hashes/HTTP passaram; docs/proofs/replenishment-pilot.json e replenishment-recovery-install.json. Não somar suites sobrepostas nem apresentar33/314 e918+2 históricos como execução nova. SGF real continua0.1.1; candidatura/transferência e identidade adicional mantêm as autorizações próprias, sem instalação nem criação neste incremento. Preservar trabalho paralelo no repositório.
+
+**02/11/2026 — acompanhamento de reservas concluído neste incremento:** /stock/requisicoes tem tarefas por perfil (autorizar/reservas/entregas/recepções), pesquisa/filtros antes da paginação e contagens completas do âmbito. WarehouseWorkdesk é projecção de leitura dos diários existentes; estados conferidos contra progress(), sem outro motor ou concessão paralela. Detalhe mostra saldo sem reserva, qualidade/origem a conferir e pessoas elegíveis actuais; falta de responsável fica visível, sem atribuição automática. Entrega continua independente, com reserva/stock revalidados. Recepção permanece pendente após cancelar só o saldo; consulta/recepção não lançam saída.
+
+Proprietário esclareceu: desenvolvimento/validação seguem com contas sintéticas, sem pessoas reais nesta fase. AGENTS.md actualizado. A avaliação persistente mantém contas/perfis/competências existentes; ensaios com actores/perfis suplementares só em bases descartáveis. Não voltar a exigir participantes reais para prosseguir nesta etapa. Isto não autoriza a identidade adicional do SGF nem instala candidatas no destino0.1.1.
+
+Provas NOVAS: regressão111/1008, final18/326, PG38/517+3disputas, tipos/lint/build/análise/18hashes e HTTP passaram. docs/proofs/warehouse-workdesk.json e ficheiros -pg/-http. Sem migração ou concessões; sem bateria geral repetida, aceitação real ou revisão visual completa. Próximos incrementos mantêm validação sintética e qualquer expiração/prioridade/notificação persistente depende de configuração explícita dos efeitos. Preservar o trabalho paralelo e a separação Compras autónomo/SGF.
+
+**02/11/2026 — diagnóstico e ensaio integrado do piloto:** /stock/piloto é consulta administrativa, sem efeitos operacionais, com sete conferências e contas/tarefas elegíveis. Verifica independência real de três intervenientes, centro/âmbito, catálogo actual, regras activas/fontes, serviço recente e trabalhos pendentes; distingue condições de ensaio de configuração comercial concluída. Reutiliza inspectRule/validação canónica sem locks de escrita. Telemetria partilhada via ReplenishmentQueue::status não aceita registo antigo/danificado como confirmação de serviço.
+
+Ensaio sintético integrado percorreu pedido125/reserva120/entrega100, entrada física108 de ordem já emitida, reforço autorizado5, entrega25 e recepção: pedido encerrado, stock103, reserva0, uma requisição automática ainda rascunho, sem factura interna, aprovação ou orçamento automáticos. Não confundir origem física preexistente com execução do novo rascunho. Só bases descartáveis, sem pessoas reais ou novas concessões na avaliação.
+
+Provas NOVAS53/705, final8/142, PG46/659+3disputas, tipos/lint/build/análise/18hashes/HTTP; docs/proofs/warehouse-pilot-readiness.json e respectivos -pg/-http. Na avaliação8005:12contas existentes; instalação/serviço conferidos, mas tarefas, catálogo, regras de reposição e configuração própria ainda a preparar. Não declarar cliente/piloto integralmente pronto nem activar perfis/valores por inferência. SGF real0.1.1 e proposta da identidade adicional continuam separados, sem instalação/autorização nova. Evidências anteriores e ausência de revisão visual completa preservadas.
+
+**02/11/2026 — diagnóstico exportável:** /stock/piloto.csv exporta as conferências datadas em UTF-8/CSV, só para administração, usando a mesma inspecção read-only. Não exporta contas nem activa configurações. Interface inclui imprimir, ainda sem revisão visual de impressão.10testes/161asserções, tipos/lint/build/análise/18hashes e HTTP passaram; docs/proofs/pilot-export.json. Manter as configurações pendentes explícitas e a validação sintética; não apresentar exportação como aprovação ou aceitação comercial. SGF e contas/perfis persistentes preservados.
+
+**02/11/2026 — ligação de entregas do armazém para integração:** WarehouseExchange, GET /stock/integracao/entregas e contrato integrations/contracts/warehouse-fulfilment-v1.json. Só leitura autenticada dos factos deliver/receive já registados; reutiliza RA, destino, equipamento, intervenientes e stock_operation_id, sem nova saída ou factura. Cursor incremental cifrado liga empresa/conta/contexto/departamento/competências; mudar âmbito exige reiniciar. Paginação preserva eventos antigos e permite novas consultas depois do fim. Consumidor deve persistir factos+cursor atomicamente e deduplicar por instalação de origem/empresa/idempotency_key; hash divergente exige reconciliação. Não usar feed como saldo actual ou réplica do motor de stock; estornos continuam no percurso próprio.
+
+Provas NOVAS41/523 na regressão dirigida e10/83 do subconjunto novo em PostgreSQL descartável; formatação/análise PHP/18hashes/HTTP passaram. Provas warehouse-exchange[-pg/-http].json. Avaliação8005 devolveu zero factos na primeira página; percursos com entregas/equipamento foram sintéticos em testes. Sem contas/grants/migrações, build ou revisão visual nova, nem instalação SGF (real0.1.1 preservado). Não somar suites sobrepostas nem confundir com a bateria geral histórica. Próxima ligação deve consumir o contrato e reutilizar os motores/fontes existentes; autenticação técnica remota e confirmação persistente do consumidor ainda não fazem parte deste incremento. Contratos de Pessoas continuam com formalização/RH próprios; não exportar rascunhos como contratos formalizados. Ver desenvolvimento.md para contrato e limites exactos.
+
+**02/11/2026 — receptor durável de entregas preparado:** packages/integration (WarehouseFeed/WarehouseInbox/WarehousePull), carregável sem Laravel. Duas definições de tabela para migração explícita do destinatário; nenhum schema instalado em aplicações persistentes. Origem+empresa de origem+empresa local+evento deduplicam factos; geração de leitor mantém checkpoint próprio. Recepção e cursor confirmados atomicamente, com versão/cursor de comparação. Retry exacto após commit sem confirmação é reconhecido; conflito/alteração de conteúdo/página antiga não avança. Recepção exige entrega original reconciliada, sem outra saída, factura ou aprovação.
+
+Leitura por lotes reutiliza consulta autenticada fornecida pelo host; falha preserva páginas anteriores e execução posterior retoma. Não há transporte remoto, credenciais técnicas, agendador ou projecção funcional do destinatário activados. Mudança de âmbito na origem pode usar nova geração técnica de leitor preservando factos/checkpoint anterior, sem criar outra conta. Exemplo e limites em packages/README.md e desenvolvimento.md. Cursor da origem agora resume permissões por hash para caber mesmo com perfil completo; os antigos exigem reinício de leitura, sem alterar eventos/chaves.
+
+Provas NOVAS56/628 na regressão dirigida;25/188 em PostgreSQL descartável, duas contenções reais (mesma página/páginas diferentes) e processo terminado após commit antes da confirmação, retomado sem duplicar. Formatação/análise PHP/18hashes/HTTP passaram. warehouse-inbox.json, warehouse-inbox-pg.json e warehouse-exchange-http.json; logs warehouse-inbox-regression.txt/warehouse-inbox-pg.txt. Suites sobrepostas, sem bateria geral/build/revisão visual nova. Preservados dados/perfis persistentes e SGF real0.1.1; identidade adicional continua sem autorização. Próximo recorte é transporte/configuração do host e reconciliação autorizada de vínculos, preservando o motor único e distinguindo diário recebido de projecção de stock/contabilidade e de instalação efectiva.
+
+**02/11/2026 — consulta HTTP autenticada implementada:** WarehouseHttpSource/WarehouseTransportFailure ligam WarehousePull ao endpoint fixo da origem através de sessão já autorizada. Não fazem login, contexto, contas ou grants. Origem HTTPS, Cookie por fornecedor protegido, query codificada, sem redirects e com verificação TLS/limites de tempo/tamanho; HTTP só em loopback explícito para desenvolvimento. Erros sanitizados distinguem expiração/âmbito/cursor/falha técnica e preservam o checkpoint. Não gravar cookies ou colocar credenciais em URLs/argumentos/logs.
+
+Provas NOVAS75/746 na regressão dirigida;44/306 em PostgreSQL descartável +2contenções reais e saída de processo após commit/antes de confirmação; formatação/análise PHP/18hashes passaram. Prova nativa cURL contra8005 reutilizou sessão sintética existente por stdin privado e receptor SQLite em memória: cursor válido e consulta sem sessão recusada,0factos na primeira página. warehouse-http-source[-pg/-http].json e logs respectivos; sem dados ou direitos empresariais persistentes alterados. Sem build/bateria geral/revisão visual/TLS comercial novo.
+
+Transporte de sessão preparado, não API técnica permanente nem integração comercial activada. Próximas lacunas: configuração administrativa/armazenamento e renovação protegidos no host, eventual autenticação técnica restrita, agendamento e reconciliação/projecção autorizada no destinatário. Não duplicar motores ou inferir implantação. SGF real0.1.1 e identidade adicional continuam sob as autorizações existentes, sem instalação/conta nova. Documentação de ligação e limites em packages/README.md/desenvolvimento.md.
+
+**02/11/2026 — integração desta etapa é interna ao SGC, conforme resposta expressa do proprietário:** AGENTS.md actualizado. Priorizar as ligações funcionais e a reutilização entre módulos, sem pressupor instalação/integração com SGF ou novos acessos externos. InternalSupplyTrace integra no detalhe do armazém necessidade→compra→aprovação pendente→encomendas→recebimentos, com ligação inversa da compra ao pedido permitido. Sem novas tabelas/formulários/credenciais ou cópia de documentos; impressão interna preservada, sem factura.
+
+Última versão que mencionou a origem define as compras historicamente ligadas; a situação actual do artigo/armazém é separada. Não atribuir compras futuras ao pedido já fora dessa origem. Reposição partilhada não equivale a afectação exclusiva nem a stock livre/utilizável. Entradas/estornos são lidos das fontes actuais; actualização de reserva é intervenção explícita no formulário existente, por pessoa elegível. WarehouseWorkdesk::canView centraliza acesso ao detalhe e às ligações; processos de compra invisíveis não revelam referências/valores. Aprovações, qualidade, stock e perfis mantêm os motores existentes.
+
+Provas NOVAS52/668 na regressão dirigida e7/105 em PostgreSQL descartável, incluindo piloto parcial/entrada de ordem preexistente/reserva/conclusão. Não somar suites sobrepostas. Teste documental de encomenda/recepção e estorno simulado verifica a projecção; não declarar que o novo rascunho percorreu adjudicação/encomenda ou que o motor de estornos foi integralmente reexecutado. Tipos/lint/build/análise PHP/18hashes e HTTP conferidos;0pedidos persistentes disponíveis para inspecção do detalhe em8005, com detalhes exercitados nas bases de testes. Sem revisão visual completa/bateria geral/migração/contas/grants/dados empresariais persistentes novos. Provas internal-supply-integration[-pg/-http].json e logs respectivos. SGF real0.1.1 e trabalho paralelo preservados. Continuar a cobertura interna por percursos concretos, sem apresentar o conjunto de propostas como concluído.
+
+**02/11/2026 — listas de tarefas ligam os módulos internos:** /requisicoes/tarefas reúne rascunhos/aprovações de compra e decisões/reservas/entregas/confirmações de armazém; /pessoas/tarefas reutiliza PeopleWorkdesk e a equipa atribuída, mantendo a área separada. Novas entradas As minhas tarefas. WorkflowTaskInbox delega a autoridade aos motores actuais e abre os formulários originais; não cria outro workflow, tabela, prazo, pessoa ou concessão. Consulta não decide nem marca avisos como lidos. Facturas/consulta/adjudicação e fases ainda não cobertas conservam os seus percursos; não declarar o catálogo inteiro concluído.
+
+Entrega bloqueada permanece como atenção; cada recepção tem tarefa/âncora própria, incluindo após cancelamento apenas do saldo. Confirmar uma entrega retira só a sua tarefa, sem novo movimento. Autorizar próprio pedido/entregar o que se autorizou continuam impedidos; administração de consulta/manutenção não recebem tarefas de execução. Empresa, departamento, contexto, competência e vigência de equipa continuam efectivos. Ordenação/paginação sobre o âmbito autorizado completo, sem corte global100 nem prioridade organizacional inventada.
+
+Provas NOVAS60/925 na regressão dirigida;15/362 em PostgreSQL descartável, incluindo8testes novos. Tipos/lint/build/análise PHP/18hashes/HTTP passaram. A conta sintética administrativa da avaliação8005 viu0tarefas em cada área; pendências e intervenções reais do ensaio apenas nas bases descartáveis. workflow-task-inbox[-pg/-http].json e logs respectivos. Não somar suites sobrepostas, nem contar a bateria geral/contenções anteriores como repetidas. Sem revisão visual completa/migração/contas/grants/dados empresariais persistentes; SGF0.1.1 e trabalho paralelo preservados. Continuar pelas funcionalidades internas em falta, usando fontes/motores existentes e mantendo Compras/Pessoas separados.
+
+**02/11/2026 — Finanças integrada no percurso interno:** a factura reutiliza as linhas congeladas da encomenda (unidade/preço/IVA); só referências, quantidades e novos dados do documento são introduzidos. Totais independentes, limites de recepção/devoluções/estornos, arredondamento, conflitos e validação por outra pessoa permanecem nos motores existentes. As minhas tarefas acrescenta Finanças para facturas e conferências de pagamentos externos; projecções de elegibilidade comuns às listas/formulários, decisões revalidadas no servidor. Catálogo de tarefas dos perfis actualizado dentro da competência financeira existente, sem concessões novas ou granularidade fictícia.
+
+Focos por factura/encomenda e pagamento/factura preservam contexto e paginação; compra → encomenda → factura → registo externo usa documentos originais e ligações concretas. Empresa, independência, manutenção e conflitos continuam efectivos. Tarefa não revela referências/evidências/contas e não produz decisão. Revisão de pendentes importados continua sujeita a activação e competência específica pa_access/validate; não há saldo alternativo quando o núcleo está indisponível. Mudança bancária permite rejeitar, mas confirmação continua bloqueada até nova conferência válida. Não há transferência bancária, segunda dívida, factura de saída interna ou ligação nova ao SGF.
+
+Provas novas: regressão 81/800; autoridade/integração financeira e catálogo 23/357; teste corrigido de activação isolado em SQLite 1/6. Tipos/lint/build/análise PHP/18 hashes e seis consultas HTTP8005 passaram. Facturas visíveis 1, pagamentos/tarefas 0 nas contas administrativas/financeiras existentes; pendências e decisões exercitadas só nas bases descartáveis. Prova PostgreSQL e artefactos de resumo acrescentados após conclusão abaixo. Sem bateria geral/revisão visual completa/migração/contas/grants/decisões persistentes; SGF real0.1.1 preservado. Consulta de pagamentos conserva auditoria de acesso. Continuar pelas fases internas ainda não representadas na lista de tarefas (consulta ao mercado, avaliações/adjudicação e execução contratual), sem substituir os motores ou confundir cobertura parcial com produto concluído. O piloto persistente continua dependente de parametrização explícita; não inventar responsáveis, artigos publicados, mínimos ou centros de custo.
+
+Prova PostgreSQL final:83/623 aprovados, base descartável removida. Após limite120s e correcção do pressuposto de ambiente num teste antigo, execução integral aprovada com limite300s; não contar tentativas anteriores como aprovações. Artefactos finance-integration[-pg/-http].json e logs correspondentes. Continuar para consulta ao mercado/adjudicação usando as autoridades existentes.
+
+**02/11/2026 — adjudicação/emissão ligada às tarefas:** PurchaseWorkflow::canDecide é a fonte da etapa final actual, incluindo congelamento por cancelamento pendente e exclusão da manutenção. canIssue partilhado por formulário/lista projecta elegibilidade para conferir a emissão após aprovação, sem prometer base válida ou executar a ordem. O comando revalida matriz/propostas, aprovações, conflitos, regras, datas e orçamento; proposta expirada mantém conferência pendente mas impede emitir. Após emissão real a tarefa desaparece. Catálogo dos perfis explica adjudicação/emissão sem concessão nova. Atribuição expirada sem outro acesso continua a produzir403, não uma lista vazia autorizada.
+
+Quatro testes novos em PostgreSQL descartável (4/217) passaram: selecção real com três propostas/avaliações, três etapas e emissão, cancelamento/rejeição, leitura sem efeito, manutenção/expiração e validade da proposta. Base removida. Tipos/lint/build/análise PHP/18hashes e HTTP dos três perfis existentes passaram;0tarefas locais de Compras/adjudicação em todos. Provas award-task-integration-pg.json, award-task-integration-http.json e logs respectivos; resumo/regressão final abaixo. Sem revisão visual/bateria geral/migração/contas/grants/decisões persistentes/alteração SGF0.1.1. A projecção não cria avisos persistentes da adjudicação. Próxima cobertura interna: tarefas da consulta ao mercado e avaliações, extraindo/reutilizando a autoridade actual antes de acrescentar linhas à lista; depois execução contratual. Não copiar guardas para um segundo workflow nem inventar responsabilidades, prazos ou regras de cliente.
+
+Regressão final aprovada:98/2070 (adjudicação/ordens, tarefas e requisições internas), com expectativa de expiração corrigida e repetição integral. Provas award-task-integration[-pg/-http].json; PostgreSQL4/217, teste isolado1/32. Suites sobrepostas, não somar. Não houve revisão visual completa nem execução da bateria geral.
+
+**02/11/2026 — consulta ao mercado integrada:** QuotationParticipation é autoridade comum a formulário/tarefas/comandos; competências recarregadas sob bloqueio da empresa. Pendências: propostas em falta pelos mínimos preservados, avaliações elegíveis enquanto falta o mínimo qualificado, impedimentos da comparação e conferência do resultado na adjudicação. Não seleccionar/aprovar automaticamente nem reabrir consulta encerrada por adjudicação rejeitada. Proposta exacta por quotation_id, preparação existente por prepare=1; origem validada e história/cache protegidos. Avaliações históricas não são substituídas quando o avaliador perde competência; a comparação reavalia qualificação. Sem prazos/pessoas/minímos inventados.169/2924 na regressão dirigida;11/353 em PostgreSQL descartável. Tipos/lint/build/análise PHP/18hashes/HTTP passaram;0tarefas locais nos três perfis. Provas quotation-task-integration[-pg/-http].json. Sem novas contas/grants/decisões persistentes/bateria geral/revisão visual/alteração SGF0.1.1.
+
+**Diagnóstico antes de activar perfis:** OrganizationActivationPreview na Parametrização compara competências actuais/perfis, preparação implícita, contextos, manutenção, tarefas e administradores. Reutiliza os mesmos resolvedores de OrganizationAccess/WorkContext e o contador de administradores do comando real; consulta consistente sem concessões/activação.6/101 novos,89/1394 regressão dirigida,19/215 PostgreSQL descartável e HTTP8005 passaram. Tipos/lint/build/análise PHP/18hashes conferidos. Provas organization-activation-preview[-pg/-http].json. Suites sobrepostas: não somar.
+
+**Próximo passo concreto sujeito a autorização:** diagnóstico local8005 versão1 tem12contas sintéticas,0perfis/atribuições,0administradores permanentes configurados e alçadas provisórias. Activação directa não é permitida pelos controlos existentes. Proposta pendente em docs/proofs/pilot-access-proposal.json e secção final de desenvolvimento.md: preservar funções/contextos/preparação implícita das12contas,11perfis (6base+5tarefas), manter administradores6/8; tarefas adicionais9requisitar,10autorizar,13entregar,11preparar reposição,12conferir reposição. Confirmar somente no piloto as mesmas alçadas25.000/150.000MZN comIVA, sem alterar valores; preservar IVA16%, processos/histórico/pa_access e SGF. Não criar pessoas reais/contas novas, publicar artigos, definir mínimos ou executar negócio. Substituição da autoridade de acesso requer escolha explícita e auditada (AGENTS.md). Proposta preparada sem aplicação, ensaio exclusivamente descartável em PilotAccessProposalTest; resultados finais anexados abaixo. Antes de aplicar após eventual autorização, conferir novamente a origem/versão e preservar os dados; nenhuma autorização do SGF decorre daqui.
+
+Proposta concreta ensaiada e pronta para decisão:SQLite1/156 ePostgreSQL1/156 aprovados, com os mesmos comandos do produto e sem aplicação local.11perfis/12atribuições, dois administradores existentes e cinco tarefas adicionais, sem perdas ou mudança de âmbito/pa_access/processos. Provas pilot-access-proposal-dry-run.json e pilot-access-proposal-pg.json. A proposta e a activação8005 continuam pendentes de autorização expressa; não confundir a simulação com mudança persistente. Próxima intervenção dependente: confirmar a autorização do proprietário e verificar novamente versão/origem antes de aplicar a distribuição concreta. Outras funcionalidades não ficam declaradas concluídas por este pedido de autorização.
+
+## 02/11/2026 — continuidade da devolução de excedente
+
+Percurso nativo implementado e ensaiado em base própria; ver docs/warehouse-unused-return.md. Manter stock_returns.enabled=false fora da QA e competências warehouse_return_* sem concessões por arrasto. Reutilizar entrega e recepção originais, confirmação independente, escritor único e auditoria; não acrescentar devolução ao formulário genérico nem confundir declaração de excedente com consumo técnico SGF. Provas válidas consolidadas36/544, sem necessidade de repetir suites inalteradas. Consulta conjunta da história51/páginas25 usa3SELECTs centrais; não é ensaio de carga.
+
+Continuam separadas implantação/composição operacional, revisão visual, carga de escrita e eventual regra de previsão líquida. A média histórica actual é por saídas brutas; devolução altera saldo e hash, preservando a convenção. Não reescrever históricos aprovados nem inventar atribuição temporal. Sem lotes/reservas/equipamento/peças/qualidade ou correcção de devolução confirmada neste recorte. Preservar os dados, cache, seis associações, demonstrações e trabalho em curso; não activar piloto/candidatas ou conceder direitos por consequência destes testes.
+
+**02/11/2026 — autorizado e aplicado o piloto de acessos:** proprietário respondeu «autorizo» à proposta exacta para SGC8005. Não voltar a pedir autorização para esta distribuição nem reaplicar o instalador. Proposta original mantém o hash/estado histórico de preparação; autorização e estado efectivo estão em pilot-access-authorization.json e pilot-access-activation.json. Configuração passou de1para27:11perfis/17vínculos em12contas existentes; administradores6/8, tarefas9requisitar/10autorizar/13entregar/11preparar reposição/12conferir reposição. Preservadas competências/contextos/preparação/âmbito anteriores. Mesmas alçadas25.000/150.000MZN comIVA confirmadas só para o piloto; IVA16% e snapshots históricos intactos.
+
+Aplicação atómica por OrganizationConfiguration, sob bloqueio da empresa e revalidação da origem/versão/hash autorizado. Backup privado antes da mudança, snapshot consistente e restauro verificado112tabelas;107tabelas protegidas e demais campos de Tenant preservados, incluindo contas/pa_access/processos/aprovações/orçamento/stock. Restore descartável removido. Prova pilot-access-activation.json e logs correspondentes. Não houve migração/contas novas/decisão operacional/publicação de artigos/mínimos/políticas/regras automáticas ou mudança SGF0.1.1. Perfis sintéticos não são defaults de entrega comercial; a empresa continua a definir os seus próprios intervenientes. Verificação HTTP posterior e pendências reais do piloto são anexadas após conclusão abaixo, sem declarar nova bateria geral ou revisão visual.
+
+**Verificação final da activação8005:** pilot-access-activation-http.json confirmou versão27,12contas,11perfis/17vínculos,2administradores e todos os acessos/contextos anteriores. Cinco perfis abriram os percursos próprios e não obtiveram administração; preparação/conferência da reposição e requisição/revisão/entrega ficaram separados. Tarefas0na situação actual; Pessoas continua separado e acessível à administração no contexto próprio. Só consultas, sem nova operação de negócio/revisão visual/bateria geral.
+
+O piloto ainda não tem artigos publicados (0), políticas internas aprovadas (0) ou regras automáticas activas (0); existe1armazém e os intervenientes independentes estão prontos. Serviço periódico/recuperação recentes e sem pendências. O indicador de configuração de cliente mantémfalse pelo requisito de contexto contratado explicitamente, distinto do âmbito sintético preservado; não acrescentar direitos comerciais por inferência. Próxima etapa: parametrizar catálogo e regras específicas sem inventar quantidades/mínimos ou responsáveis. Manter esta separação entre acesso já autorizado/aplicado e configuração operacional/comercial ainda por concluir.
diff --git a/docs/proofs/approval-routing-http.json b/docs/proofs/approval-routing-http.json
index 7e50b0f..e25af67 100644
--- a/docs/proofs/approval-routing-http.json
+++ b/docs/proofs/approval-routing-http.json
@@ -1,5 +1,5 @@
{
- "checked_at": "2026-11-01T16:31:19.533Z",
+ "checked_at": "2026-11-02T10:11:34.369Z",
"environment": "http://127.0.0.1:8005",
"profile_task_catalogue": "passed",
"workflow_notices": "ready",
diff --git a/docs/proofs/award-task-integration-http.json b/docs/proofs/award-task-integration-http.json
new file mode 100644
index 0000000..18fa244
--- /dev/null
+++ b/docs/proofs/award-task-integration-http.json
@@ -0,0 +1,29 @@
+{
+ "checked_at": "2026-11-02T16:52:13.253Z",
+ "environment": "http://127.0.0.1:8005",
+ "routes": [
+ {
+ "role": "procurement",
+ "total_purchase_tasks": 0,
+ "shown_award_tasks": 0,
+ "result": "passed"
+ },
+ {
+ "role": "department_head",
+ "total_purchase_tasks": 0,
+ "shown_award_tasks": 0,
+ "result": "passed"
+ },
+ {
+ "role": "finance",
+ "total_purchase_tasks": 0,
+ "shown_award_tasks": 0,
+ "result": "passed"
+ }
+ ],
+ "existing_accounts_used": true,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "visual_review": false,
+ "note": "Only current first-page tasks are followed; disposable tests cover pending approval and issuance."
+}
\ No newline at end of file
diff --git a/docs/proofs/award-task-integration-pg.json b/docs/proofs/award-task-integration-pg.json
new file mode 100644
index 0000000..14a0161
--- /dev/null
+++ b/docs/proofs/award-task-integration-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T16:53:11+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":4,\"passed\":4,\"assertions\":217,\"duration_ms\":11934}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/award-task-integration.json b/docs/proofs/award-task-integration.json
new file mode 100644
index 0000000..db271a1
--- /dev/null
+++ b/docs/proofs/award-task-integration.json
@@ -0,0 +1,45 @@
+{
+ "checked_at": "2026-11-02T16:55:21.5246476Z",
+ "scope": "Native SGC award decisions and order issuance task routing",
+ "regression": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 98,
+ "passed": 98,
+ "assertions": 2070,
+ "duration_ms": 76232
+ },
+ "postgres": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 4,
+ "passed": 4,
+ "assertions": 217,
+ "duration_ms": 11934
+ },
+ "targeted_revocation": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 1,
+ "passed": 1,
+ "assertions": 32,
+ "duration_ms": 2740
+ },
+ "typescript": "passed",
+ "lint_build": "passed",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "http_proof": "award-task-integration-http.json",
+ "persistent_accounts_or_grants_changed": false,
+ "persistent_business_commands": false,
+ "sgf_installation_changed": false,
+ "visual_review": false,
+ "general_suite_rerun": false,
+ "limitations": [
+ "Issuance task means review required, not guaranteed issuance readiness",
+ "Quotation, documentary and contractual tasks remain outside this increment",
+ "Existing QA profiles had no current purchase task",
+ "Overlapping suites must not be summed"
+ ]
+}
diff --git a/docs/proofs/finance-integration-http.json b/docs/proofs/finance-integration-http.json
new file mode 100644
index 0000000..e066f93
--- /dev/null
+++ b/docs/proofs/finance-integration-http.json
@@ -0,0 +1,48 @@
+{
+ "checked_at": "2026-11-02T16:38:36.337Z",
+ "environment": "http://127.0.0.1:8005",
+ "routes": [
+ {
+ "role": "admin",
+ "path": "/facturas",
+ "shown": 1,
+ "result": "passed"
+ },
+ {
+ "role": "admin",
+ "path": "/pagamentos",
+ "shown": 0,
+ "result": "passed"
+ },
+ {
+ "role": "admin",
+ "path": "/requisicoes/tarefas?module=finance",
+ "total": 0,
+ "result": "passed"
+ },
+ {
+ "role": "finance",
+ "path": "/facturas",
+ "shown": 1,
+ "result": "passed"
+ },
+ {
+ "role": "finance",
+ "path": "/pagamentos",
+ "shown": 0,
+ "result": "passed"
+ },
+ {
+ "role": "finance",
+ "path": "/requisicoes/tarefas?module=finance",
+ "total": 0,
+ "result": "passed"
+ }
+ ],
+ "existing_accounts_used": true,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "visual_review": false,
+ "note": "Payment consultation retains its existing access audit; no financial decision is executed."
+}
\ No newline at end of file
diff --git a/docs/proofs/finance-integration-pg.json b/docs/proofs/finance-integration-pg.json
new file mode 100644
index 0000000..532a55a
--- /dev/null
+++ b/docs/proofs/finance-integration-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T16:45:16+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":83,\"passed\":83,\"assertions\":623,\"duration_ms\":112485}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/finance-integration.json b/docs/proofs/finance-integration.json
new file mode 100644
index 0000000..a73a487
--- /dev/null
+++ b/docs/proofs/finance-integration.json
@@ -0,0 +1,45 @@
+{
+ "checked_at": "2026-11-02T16:46:16.6004450Z",
+ "scope": "native SGC invoice, external payment and task integration",
+ "regression": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 81,
+ "passed": 81,
+ "assertions": 800,
+ "duration_ms": 76021
+ },
+ "authority": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 23,
+ "passed": 23,
+ "assertions": 357,
+ "duration_ms": 40232
+ },
+ "postgres": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 83,
+ "passed": 83,
+ "assertions": 623,
+ "duration_ms": 112485
+ },
+ "typescript": "passed",
+ "frontend_lint_and_build": "passed",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "http_proof": "finance-integration-http.json",
+ "visual_review": false,
+ "general_suite_rerun": false,
+ "persistent_accounts_or_grants_changed": false,
+ "persistent_business_decisions": false,
+ "sgf_installation_changed": false,
+ "limitations": [
+ "No full visual review or commercial certification",
+ "QA current data contains no pending financial task",
+ "Existing payment access audit preserved",
+ "Suites overlap and must not be summed"
+ ]
+}
diff --git a/docs/proofs/formal-task-integration-http.json b/docs/proofs/formal-task-integration-http.json
new file mode 100644
index 0000000..5065f7a
--- /dev/null
+++ b/docs/proofs/formal-task-integration-http.json
@@ -0,0 +1,69 @@
+{
+ "checked_at": "2026-11-02T18:21:22.559Z",
+ "environment": "http://127.0.0.1:8005",
+ "result": "passed",
+ "routes": [
+ {
+ "role": "procurement",
+ "formal_tasks_shown": 0,
+ "task_links_followed": 0,
+ "components": [
+ {
+ "component": "purchases/qualification",
+ "result": "passed"
+ },
+ {
+ "component": "purchases/admission",
+ "result": "passed"
+ },
+ {
+ "component": "purchases/recommendation",
+ "result": "passed"
+ }
+ ]
+ },
+ {
+ "role": "finance",
+ "formal_tasks_shown": 0,
+ "task_links_followed": 0,
+ "components": [
+ {
+ "component": "purchases/qualification",
+ "result": "passed"
+ },
+ {
+ "component": "purchases/admission",
+ "result": "passed"
+ },
+ {
+ "component": "purchases/recommendation",
+ "result": "passed"
+ }
+ ]
+ },
+ {
+ "role": "department_head",
+ "formal_tasks_shown": 0,
+ "task_links_followed": 0,
+ "components": [
+ {
+ "component": "purchases/qualification",
+ "result": "passed"
+ },
+ {
+ "component": "purchases/admission",
+ "result": "passed"
+ },
+ {
+ "component": "purchases/recommendation",
+ "result": "passed"
+ }
+ ]
+ }
+ ],
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "visual_review": false,
+ "coverage": "Existing current scope and first-page tasks only; pending decisions tested in disposable databases."
+}
\ No newline at end of file
diff --git a/docs/proofs/formal-task-integration-pg.json b/docs/proofs/formal-task-integration-pg.json
new file mode 100644
index 0000000..e8e4977
--- /dev/null
+++ b/docs/proofs/formal-task-integration-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T18:16:10+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":11,\"passed\":11,\"assertions\":383,\"duration_ms\":35159}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/internal-supply-integration-http.json b/docs/proofs/internal-supply-integration-http.json
new file mode 100644
index 0000000..55ec366
--- /dev/null
+++ b/docs/proofs/internal-supply-integration-http.json
@@ -0,0 +1,13 @@
+{
+ "checked_at": "2026-11-02T15:23:04.604Z",
+ "environment": "http://127.0.0.1:8005",
+ "result": "passed",
+ "internal_sgc_only": true,
+ "warehouse_index": true,
+ "existing_request_details_checked": 0,
+ "synthetic_journeys_in_disposable_tests": true,
+ "persistent_business_commands": false,
+ "accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/internal-supply-integration-pg.json b/docs/proofs/internal-supply-integration-pg.json
new file mode 100644
index 0000000..122b7d9
--- /dev/null
+++ b/docs/proofs/internal-supply-integration-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T15:22:15+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":7,\"passed\":7,\"assertions\":105,\"duration_ms\":36419}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/internal-supply-integration.json b/docs/proofs/internal-supply-integration.json
new file mode 100644
index 0000000..08e42c5
--- /dev/null
+++ b/docs/proofs/internal-supply-integration.json
@@ -0,0 +1,40 @@
+{
+ "checked_at": "2026-11-02T15:29:13.7856940Z",
+ "scope": "internal SGC modules only, explicitly selected by owner",
+ "module": "warehouse replenishment purchasing receipts trace",
+ "regression": {
+ "tests": 52,
+ "assertions": 668,
+ "result": "passed",
+ "log": "storage/logs/internal-supply-integration-regression.txt"
+ },
+ "disposable_postgresql": {
+ "tests": 7,
+ "assertions": 105,
+ "proof": "internal-supply-integration-pg.json",
+ "result": "passed"
+ },
+ "http": "internal-supply-integration-http.json",
+ "source_sha256": {
+ "app/Services/InternalSupplyTrace.php": "f705434f8db45b671f370e228db3b49f308abe1483aed2878e883f9fa04b147d",
+ "app/Services/WarehouseWorkdesk.php": "a4312b5395a1ab64003a84f54c813f70ae5f08c0acb9f506948098ceb5bac6b1",
+ "app/Http/Controllers/WarehouseRequestController.php": "2b5df44456424f7bac0657b62496d2297b372bdcb794e2889352bbce0c841a72",
+ "app/Http/Controllers/RequisitionController.php": "8f1ce0790ed738682f2d1a178c373e545e7a6f7a8c07d2a256b11a34539c37c1",
+ "resources/js/pages/stock/request.tsx": "d0de70b8ff4c67247f38885fa9e1ca69432073961d2bffc6b6da61c36f47e02f",
+ "resources/js/pages/requisitions/show.tsx": "8db5e514e70532ad8eabc559834eed63127d10050ce8b714fb45ada646c40951"
+ },
+ "types": "passed",
+ "phpstan": "passed",
+ "frontend_lint": "passed",
+ "frontend_build": "passed",
+ "runtime_hashes": 18,
+ "canonical_purchase_draft_and_human_submission_tested": true,
+ "full_new_purchase_to_order_journey_claimed": false,
+ "documentary_receipt_projection_fixture": true,
+ "reversal_classification_mocked_in_projection_test": true,
+ "new_persistent_schema_accounts_grants_or_business_records": false,
+ "remote_access_activated": false,
+ "sgf_installation_changed": false,
+ "full_general_suite_rerun": false,
+ "visual_review": false
+}
diff --git a/docs/proofs/organization-activation-preview-http.json b/docs/proofs/organization-activation-preview-http.json
new file mode 100644
index 0000000..02abda5
--- /dev/null
+++ b/docs/proofs/organization-activation-preview-http.json
@@ -0,0 +1,17 @@
+{
+ "checked_at": "2026-11-02T17:20:38.379Z",
+ "environment": "http://127.0.0.1:8005",
+ "result": "passed",
+ "version": 1,
+ "active": false,
+ "users": 12,
+ "losing_access": 12,
+ "permanent_administrators": 0,
+ "can_activate": false,
+ "provisional_rules": true,
+ "existing_account_used": true,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/organization-activation-preview-pg.json b/docs/proofs/organization-activation-preview-pg.json
new file mode 100644
index 0000000..5dbfe59
--- /dev/null
+++ b/docs/proofs/organization-activation-preview-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T17:19:58+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":19,\"passed\":19,\"assertions\":215,\"duration_ms\":27349}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/organization-activation-preview.json b/docs/proofs/organization-activation-preview.json
new file mode 100644
index 0000000..94960f7
--- /dev/null
+++ b/docs/proofs/organization-activation-preview.json
@@ -0,0 +1,35 @@
+{
+ "verification": {
+ "checked_at": "2026-11-02T17:28:38.0997373Z",
+ "typescript": "passed",
+ "lint_build": "passed",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "visual_review": false,
+ "general_suite_rerun": false,
+ "persistent_accounts_or_grants_changed": false,
+ "persistent_business_commands": false,
+ "sgf_installation_changed": false,
+ "overlapping_suites_must_not_be_summed": true
+ },
+ "regression": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 89,
+ "passed": 89,
+ "assertions": 1394,
+ "duration_ms": 75748
+ },
+ "postgres": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 19,
+ "passed": 19,
+ "assertions": 215,
+ "duration_ms": 27349
+ },
+ "http_proof": "organization-activation-preview-http.json",
+ "scope": "Read-only activation effects using canonical permissions, context and permanent-administrator sources",
+ "activation_applied": false
+}
diff --git a/docs/proofs/people-reports-http.json b/docs/proofs/people-reports-http.json
index 8a75c22..1494649 100644
--- a/docs/proofs/people-reports-http.json
+++ b/docs/proofs/people-reports-http.json
@@ -1,5 +1,5 @@
{
- "checked_at": "2026-11-01T14:26:12.336Z",
+ "checked_at": "2026-11-02T10:11:38.749Z",
"environment": "http://127.0.0.1:8005",
"pages": [
{
diff --git a/docs/proofs/pilot-access-activation-http.json b/docs/proofs/pilot-access-activation-http.json
new file mode 100644
index 0000000..e8d0d21
--- /dev/null
+++ b/docs/proofs/pilot-access-activation-http.json
@@ -0,0 +1,95 @@
+{
+ "checked_at": "2026-11-02T17:55:28.896Z",
+ "environment": "http://127.0.0.1:8005",
+ "result": "passed",
+ "active": true,
+ "version": 27,
+ "users": 12,
+ "profiles": 11,
+ "memberships": 17,
+ "permanent_administrators": 2,
+ "original_access_and_contexts_preserved": true,
+ "first_limit": "25000.00",
+ "second_limit": "150000.00",
+ "includes_tax": true,
+ "provisional_rules": false,
+ "participants_separated": true,
+ "roles": [
+ {
+ "role": "requester",
+ "route": "/stock/requisicoes",
+ "result": "passed",
+ "tasks": 0
+ },
+ {
+ "role": "department_head",
+ "route": "/stock/requisicoes?view=review",
+ "result": "passed",
+ "tasks": 0
+ },
+ {
+ "role": "warehouse",
+ "route": "/stock/requisicoes?view=delivery",
+ "result": "passed",
+ "tasks": 0
+ },
+ {
+ "role": "procurement",
+ "route": "/stock/reposicao",
+ "result": "passed",
+ "tasks": 0
+ },
+ {
+ "role": "finance",
+ "route": "/stock/reposicao",
+ "result": "passed",
+ "tasks": 0
+ }
+ ],
+ "readiness": {
+ "warehouse_scenario_ready": false,
+ "replenishment_scenario_ready": false,
+ "client_configuration_complete": false,
+ "checks": [
+ {
+ "key": "installation",
+ "ok": true,
+ "detail": "Requisições, reservas, catálogo e recuperação durável instalados."
+ },
+ {
+ "key": "participants",
+ "ok": true,
+ "detail": "Há combinação elegível de requisitante, autorizador e entregador distintos, com centro de custo do departamento."
+ },
+ {
+ "key": "catalogue",
+ "ok": false,
+ "detail": "1 armazém(ns), 0 artigo(s) publicado(s) com origem actual e 0 publicação(ões) a rever."
+ },
+ {
+ "key": "replenishment",
+ "ok": false,
+ "detail": "0 política(s) interna(s) aprovada(s), 0 regra(s) activa(s) válida(s) e 0 a rever. Falta de stock não é, por si, erro de configuração."
+ },
+ {
+ "key": "scheduler",
+ "ok": true,
+ "detail": "Há uma passagem recente conhecida do serviço."
+ },
+ {
+ "key": "administration",
+ "ok": false,
+ "detail": "Perfis próprios activos; 2 administrador(es) permanente(s). Conferir também o contexto interno contratado/activado."
+ },
+ {
+ "key": "recovery",
+ "ok": true,
+ "detail": "0 sinal(is) por processar e 0 trabalho(s) com recuperação pendente. Necessidades funcionais sem configuração válida continuam visíveis na reposição."
+ }
+ ]
+ },
+ "business_commands_executed": false,
+ "new_accounts": false,
+ "sgf_changes": false,
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/pilot-access-activation.json b/docs/proofs/pilot-access-activation.json
new file mode 100644
index 0000000..75ae348
--- /dev/null
+++ b/docs/proofs/pilot-access-activation.json
@@ -0,0 +1,27 @@
+{
+ "result": "applied",
+ "checked_at": "2026-11-02T17:52:30+00:00",
+ "environment": "http:\/\/127.0.0.1:8005",
+ "proposal_sha256": "ff45883f1a0e1e3ab228f8bc4e6c03a66e9a5953b04af44b2e26cd6b74b60a44",
+ "source_version": 1,
+ "active_version": 27,
+ "users_preserved": 12,
+ "profiles_created": 11,
+ "memberships_created": 17,
+ "administrators_preserved": [
+ 6,
+ 8
+ ],
+ "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-access-20261102-175214-d26e4c\/before.dump",
+ "backup_sha256": "afa964b52ea6c552466360a2ebc2523126f37ccf0a3aecd21f822a0767d35d75",
+ "restore_verified_tables": 112,
+ "protected_tables_preserved": 107,
+ "tenant_other_fields_preserved": true,
+ "business_data_preserved": true,
+ "payables_specific_grants_preserved": true,
+ "new_accounts": false,
+ "new_stock_publications_or_policies": false,
+ "business_decisions_executed": false,
+ "sgf_installation_changed": false,
+ "atomic_transaction": true
+}
\ No newline at end of file
diff --git a/docs/proofs/pilot-access-authorization.json b/docs/proofs/pilot-access-authorization.json
new file mode 100644
index 0000000..efe3e2a
--- /dev/null
+++ b/docs/proofs/pilot-access-authorization.json
@@ -0,0 +1,10 @@
+{
+ "recorded_at": "2026-11-02T17:50:38.2941267Z",
+ "owner_reply": "autorizo",
+ "authorization_source": "Resposta expressa do proprietário à pergunta de activação da proposta concreta neste chat",
+ "environment": "http://127.0.0.1:8005",
+ "scope": "synthetic_local_pilot_only",
+ "proposal_sha256": "ff45883f1a0e1e3ab228f8bc4e6c03a66e9a5953b04af44b2e26cd6b74b60a44",
+ "new_accounts_authorized": false,
+ "sgf_changes_authorized": false
+}
diff --git a/docs/proofs/pilot-access-proposal-dry-run.json b/docs/proofs/pilot-access-proposal-dry-run.json
new file mode 100644
index 0000000..73e763f
--- /dev/null
+++ b/docs/proofs/pilot-access-proposal-dry-run.json
@@ -0,0 +1,43 @@
+{
+ "verification": {
+ "checked_at": "2026-11-02T17:28:38.0997373Z",
+ "typescript": "passed",
+ "lint_build": "passed",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "visual_review": false,
+ "general_suite_rerun": false,
+ "persistent_accounts_or_grants_changed": false,
+ "persistent_business_commands": false,
+ "sgf_installation_changed": false,
+ "overlapping_suites_must_not_be_summed": true
+ },
+ "proposal_status": "pending_owner_authorization",
+ "proposal_sha256": "FF45883F1A0E1E3AB228F8BC4E6C03A66E9A5953B04AF44B2E26CD6B74B60A44",
+ "sqlite": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 1,
+ "passed": 1,
+ "assertions": 156,
+ "duration_ms": 9139
+ },
+ "postgres": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 1,
+ "passed": 1,
+ "assertions": 156,
+ "duration_ms": 15787
+ },
+ "scope": "Disposable simulation of all proposed profile, assignment, rule, context and explicit activation commands",
+ "users_preserved": 12,
+ "base_profiles": 6,
+ "task_profiles": 5,
+ "existing_administrators_preserved": [
+ 6,
+ 8
+ ],
+ "local_application_changed": false
+}
diff --git a/docs/proofs/pilot-access-proposal-pg.json b/docs/proofs/pilot-access-proposal-pg.json
new file mode 100644
index 0000000..fa7cc3f
--- /dev/null
+++ b/docs/proofs/pilot-access-proposal-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T17:25:11+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":1,\"passed\":1,\"assertions\":156,\"duration_ms\":15787}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/pilot-access-proposal.json b/docs/proofs/pilot-access-proposal.json
new file mode 100644
index 0000000..ef788f5
--- /dev/null
+++ b/docs/proofs/pilot-access-proposal.json
@@ -0,0 +1,459 @@
+{
+ "status": "pending_owner_authorization",
+ "scope": "synthetic_local_pilot_only",
+ "environment": "http:\/\/127.0.0.1:8005",
+ "prepared_at": "2026-11-02T17:24:12+00:00",
+ "source_captured_at": "2026-11-02T17:20:38.379Z",
+ "source_sha256": "c3192a7ab7f52b5220ebbb1d739ad4745433f6a0023cd163466246b1b1a90548",
+ "source_version": 1,
+ "current_rules": {
+ "first_limit": "25000.00",
+ "second_limit": "150000.00",
+ "includes_tax": true,
+ "provisional": true,
+ "default_tax_rate": "16.00"
+ },
+ "proposed_rules": {
+ "first_limit": "25000.00",
+ "second_limit": "150000.00",
+ "includes_tax": true,
+ "provisional": false,
+ "default_tax_rate": "16.00"
+ },
+ "enabled_contexts": [
+ "procurement.internal",
+ "procurement.sales",
+ "people.internal",
+ "people.third_party"
+ ],
+ "profiles": [
+ {
+ "key": "base.department_head",
+ "name": "Piloto sintético — base department_head",
+ "permissions": [
+ "department_head",
+ "requester"
+ ],
+ "work_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ]
+ },
+ {
+ "key": "task.10",
+ "name": "Piloto sintético — Autorizar requisições internas",
+ "permissions": [
+ "warehouse_request_review"
+ ],
+ "work_contexts": [
+ "procurement.internal"
+ ]
+ },
+ {
+ "key": "base.finance",
+ "name": "Piloto sintético — base finance",
+ "permissions": [
+ "finance",
+ "requester"
+ ],
+ "work_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ]
+ },
+ {
+ "key": "task.12",
+ "name": "Piloto sintético — Conferir reposição",
+ "permissions": [
+ "supply_review"
+ ],
+ "work_contexts": [
+ "procurement.internal"
+ ]
+ },
+ {
+ "key": "base.procurement",
+ "name": "Piloto sintético — base procurement",
+ "permissions": [
+ "procurement",
+ "requester"
+ ],
+ "work_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ]
+ },
+ {
+ "key": "base.warehouse",
+ "name": "Piloto sintético — base warehouse",
+ "permissions": [
+ "warehouse",
+ "requester"
+ ],
+ "work_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ]
+ },
+ {
+ "key": "base.admin",
+ "name": "Piloto sintético — base admin",
+ "permissions": [
+ "admin",
+ "requester"
+ ],
+ "work_contexts": [
+ "procurement.internal",
+ "procurement.sales",
+ "people.internal",
+ "people.third_party"
+ ]
+ },
+ {
+ "key": "task.11",
+ "name": "Piloto sintético — Preparar reposição",
+ "permissions": [
+ "supply_prepare"
+ ],
+ "work_contexts": [
+ "procurement.internal"
+ ]
+ },
+ {
+ "key": "task.13",
+ "name": "Piloto sintético — Entregar requisições internas",
+ "permissions": [
+ "warehouse_request_deliver"
+ ],
+ "work_contexts": [
+ "procurement.internal"
+ ]
+ },
+ {
+ "key": "base.requester",
+ "name": "Piloto sintético — base requester",
+ "permissions": [
+ "requester"
+ ],
+ "work_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ]
+ },
+ {
+ "key": "task.9",
+ "name": "Piloto sintético — Requisitar ao armazém",
+ "permissions": [
+ "warehouse_request_prepare"
+ ],
+ "work_contexts": [
+ "procurement.internal"
+ ]
+ }
+ ],
+ "users": [
+ {
+ "id": 10,
+ "name": "Chefe de departamento — avaliação local",
+ "department_id": 1,
+ "current_permissions": [
+ "department_head"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.department_head",
+ "task.10"
+ ],
+ "planned_permissions": [
+ "department_head",
+ "requester",
+ "warehouse_request_review"
+ ],
+ "added_task_permissions": [
+ "warehouse_request_review"
+ ]
+ },
+ {
+ "id": 12,
+ "name": "Finanças — avaliação local",
+ "department_id": 1,
+ "current_permissions": [
+ "finance"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.finance",
+ "task.12"
+ ],
+ "planned_permissions": [
+ "finance",
+ "requester",
+ "supply_review"
+ ],
+ "added_task_permissions": [
+ "supply_review"
+ ]
+ },
+ {
+ "id": 1,
+ "name": "Joan Volkman",
+ "department_id": 1,
+ "current_permissions": [
+ "procurement"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.procurement"
+ ],
+ "planned_permissions": [
+ "procurement",
+ "requester"
+ ],
+ "added_task_permissions": []
+ },
+ {
+ "id": 3,
+ "name": "Kira Rowe",
+ "department_id": 1,
+ "current_permissions": [
+ "warehouse"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.warehouse"
+ ],
+ "planned_permissions": [
+ "warehouse",
+ "requester"
+ ],
+ "added_task_permissions": []
+ },
+ {
+ "id": 2,
+ "name": "Newell Oberbrunner",
+ "department_id": 1,
+ "current_permissions": [
+ "warehouse"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.warehouse"
+ ],
+ "planned_permissions": [
+ "warehouse",
+ "requester"
+ ],
+ "added_task_permissions": []
+ },
+ {
+ "id": 6,
+ "name": "Obrigações — consulta",
+ "department_id": 1,
+ "current_permissions": [
+ "admin"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales",
+ "people.internal",
+ "people.third_party"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.admin"
+ ],
+ "planned_permissions": [
+ "admin",
+ "requester"
+ ],
+ "added_task_permissions": []
+ },
+ {
+ "id": 4,
+ "name": "Obrigações — preparacao",
+ "department_id": 1,
+ "current_permissions": [
+ "finance"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.finance"
+ ],
+ "planned_permissions": [
+ "finance",
+ "requester"
+ ],
+ "added_task_permissions": []
+ },
+ {
+ "id": 5,
+ "name": "Obrigações — validacao",
+ "department_id": 1,
+ "current_permissions": [
+ "finance"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.finance"
+ ],
+ "planned_permissions": [
+ "finance",
+ "requester"
+ ],
+ "added_task_permissions": []
+ },
+ {
+ "id": 11,
+ "name": "Procurement — avaliação local",
+ "department_id": 1,
+ "current_permissions": [
+ "procurement"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.procurement",
+ "task.11"
+ ],
+ "planned_permissions": [
+ "procurement",
+ "requester",
+ "supply_prepare"
+ ],
+ "added_task_permissions": [
+ "supply_prepare"
+ ]
+ },
+ {
+ "id": 8,
+ "name": "Proprietário — avaliação local",
+ "department_id": 1,
+ "current_permissions": [
+ "admin"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales",
+ "people.internal",
+ "people.third_party"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.admin"
+ ],
+ "planned_permissions": [
+ "admin",
+ "requester"
+ ],
+ "added_task_permissions": []
+ },
+ {
+ "id": 13,
+ "name": "Recepção \/ Armazém — avaliação local",
+ "department_id": 1,
+ "current_permissions": [
+ "warehouse"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.warehouse",
+ "task.13"
+ ],
+ "planned_permissions": [
+ "warehouse",
+ "requester",
+ "warehouse_request_deliver"
+ ],
+ "added_task_permissions": [
+ "warehouse_request_deliver"
+ ]
+ },
+ {
+ "id": 9,
+ "name": "Solicitante — avaliação local",
+ "department_id": 1,
+ "current_permissions": [
+ "requester"
+ ],
+ "current_contexts": [
+ "procurement.internal",
+ "procurement.sales"
+ ],
+ "current_can_request": true,
+ "profile_keys": [
+ "base.requester",
+ "task.9"
+ ],
+ "planned_permissions": [
+ "requester",
+ "warehouse_request_prepare"
+ ],
+ "added_task_permissions": [
+ "warehouse_request_prepare"
+ ]
+ }
+ ],
+ "permanent_administrator_source_ids": [
+ 6,
+ 8
+ ],
+ "effects": {
+ "activate_saved_profiles": true,
+ "preserve_existing_permissions_and_contexts": true,
+ "preserve_legacy_request_preparation": true,
+ "make_existing_approval_limits_explicit_for_synthetic_pilot": true,
+ "new_accounts": false,
+ "change_existing_process_snapshots": false,
+ "change_payables_specific_grants": false,
+ "publish_stock_or_set_minimum_quantities": false,
+ "change_sgf": false
+ },
+ "departments": [
+ {
+ "id": 1,
+ "name": "Operações"
+ }
+ ],
+ "cost_centers": [
+ {
+ "id": 1,
+ "code": "OPS",
+ "name": "Operações"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/proofs/pilot-export-http.json b/docs/proofs/pilot-export-http.json
new file mode 100644
index 0000000..ed0bd08
--- /dev/null
+++ b/docs/proofs/pilot-export-http.json
@@ -0,0 +1,10 @@
+{
+ "checked_at": "2026-11-02T12:09:52.229Z",
+ "environment": "http://127.0.0.1:8005",
+ "csv": "passed",
+ "dated_configuration_checks": true,
+ "account_register_exported": false,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "visual_print_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/pilot-export.json b/docs/proofs/pilot-export.json
new file mode 100644
index 0000000..9e9deda
--- /dev/null
+++ b/docs/proofs/pilot-export.json
@@ -0,0 +1,37 @@
+{
+ "checked_at": "2026-11-02T12:11:33.4980997Z",
+ "scope": "SGC synthetic pilot diagnostics export",
+ "tests": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 10,
+ "passed": 10,
+ "assertions": 161,
+ "duration_ms": 3994
+ },
+ "http": {
+ "checked_at": "2026-11-02T12:09:52.229Z",
+ "environment": "http://127.0.0.1:8005",
+ "csv": "passed",
+ "dated_configuration_checks": true,
+ "account_register_exported": false,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "visual_print_review": false
+ },
+ "checks": {
+ "types": "passed",
+ "formatting_and_lint": "passed",
+ "build": "passed",
+ "scoped_php_analysis": "passed",
+ "common_runtime_sha256_files": 18
+ },
+ "administration_only": true,
+ "account_register_exported": false,
+ "spreadsheet_formula_text_handled": true,
+ "configuration_or_business_commands": false,
+ "new_persistent_accounts_or_grants": false,
+ "sgf_real_changed": false,
+ "visual_print_review": false,
+ "historical_general_suite_rerun": false
+}
diff --git a/docs/proofs/quotation-task-integration-http.json b/docs/proofs/quotation-task-integration-http.json
new file mode 100644
index 0000000..9309656
--- /dev/null
+++ b/docs/proofs/quotation-task-integration-http.json
@@ -0,0 +1,33 @@
+{
+ "checked_at": "2026-11-02T17:10:47.106Z",
+ "environment": "http://127.0.0.1:8005",
+ "routes": [
+ {
+ "role": "procurement",
+ "purchase_tasks": 0,
+ "shown_quotation_tasks": 0,
+ "followed": 0,
+ "result": "passed"
+ },
+ {
+ "role": "finance",
+ "purchase_tasks": 0,
+ "shown_quotation_tasks": 0,
+ "followed": 0,
+ "result": "passed"
+ },
+ {
+ "role": "department_head",
+ "purchase_tasks": 0,
+ "shown_quotation_tasks": 0,
+ "followed": 0,
+ "result": "passed"
+ }
+ ],
+ "existing_accounts_used": true,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "visual_review": false,
+ "coverage": "Current first-page tasks only; complete pending journeys covered by disposable tests."
+}
\ No newline at end of file
diff --git a/docs/proofs/quotation-task-integration-pg.json b/docs/proofs/quotation-task-integration-pg.json
new file mode 100644
index 0000000..087e560
--- /dev/null
+++ b/docs/proofs/quotation-task-integration-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T17:10:35+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":11,\"passed\":11,\"assertions\":353,\"duration_ms\":36918}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/quotation-task-integration.json b/docs/proofs/quotation-task-integration.json
new file mode 100644
index 0000000..02982bc
--- /dev/null
+++ b/docs/proofs/quotation-task-integration.json
@@ -0,0 +1,39 @@
+{
+ "verification": {
+ "checked_at": "2026-11-02T17:28:38.0997373Z",
+ "typescript": "passed",
+ "lint_build": "passed",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "visual_review": false,
+ "general_suite_rerun": false,
+ "persistent_accounts_or_grants_changed": false,
+ "persistent_business_commands": false,
+ "sgf_installation_changed": false,
+ "overlapping_suites_must_not_be_summed": true
+ },
+ "regression": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 169,
+ "passed": 169,
+ "assertions": 2924,
+ "duration_ms": 145167
+ },
+ "postgres": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 11,
+ "passed": 11,
+ "assertions": 353,
+ "duration_ms": 36918
+ },
+ "http_proof": "quotation-task-integration-http.json",
+ "scope": "Native quotation preparation, qualified pending evaluation, comparison and award routing",
+ "limitations": [
+ "No automatic selection or approval",
+ "Formal admission, documentary and contractual tasks remain distinct",
+ "Current persistent QA profiles had no pending quotation task"
+ ]
+}
diff --git a/docs/proofs/replenishment-pilot-http.json b/docs/proofs/replenishment-pilot-http.json
new file mode 100644
index 0000000..c58ce80
--- /dev/null
+++ b/docs/proofs/replenishment-pilot-http.json
@@ -0,0 +1,25 @@
+{
+ "checked_at": "2026-11-02T11:45:30.639Z",
+ "environment": "http://127.0.0.1:8005",
+ "component": "stock/replenishment",
+ "recovery_installed": true,
+ "pagination": [
+ "rules",
+ "needs",
+ "history",
+ "workItems"
+ ],
+ "filter": "pending",
+ "scheduler": {
+ "started_at": "2026-11-02T11:44:58+00:00",
+ "finished_at": "2026-11-02T11:45:00+00:00",
+ "pid": 24528,
+ "scope": "SGC evaluation 8005",
+ "result": "passed",
+ "exit_code": 0
+ },
+ "administrator_read_only": true,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "visual_browser_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/replenishment-pilot-pg.json b/docs/proofs/replenishment-pilot-pg.json
new file mode 100644
index 0000000..4ddbde2
--- /dev/null
+++ b/docs/proofs/replenishment-pilot-pg.json
@@ -0,0 +1,32 @@
+{
+ "checked_at": "2026-11-02T10:40:05+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":33,\"passed\":33,\"assertions\":310,\"duration_ms\":25855}",
+ "races": [
+ {
+ "scenario": "same_operation",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_operations",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "abrupt_exit_after_physical_commit_then_two_recovery_workers",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "no_authenticated_human": true,
+ "result": "passed"
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/replenishment-pilot.json b/docs/proofs/replenishment-pilot.json
new file mode 100644
index 0000000..a1c9fdf
--- /dev/null
+++ b/docs/proofs/replenishment-pilot.json
@@ -0,0 +1,123 @@
+{
+ "checked_at": "2026-11-02T10:43:28.0372487Z",
+ "scope": "SGC standalone controlled pilot",
+ "delivered": [
+ "transactional durable replenishment signal",
+ "atomic draft and processing checkpoint",
+ "retry after interruption without duplicate drafts",
+ "scheduled minimum and validity reevaluation without stock movement",
+ "separate pagination and pending filters",
+ "authorized recovery of interrupted work",
+ "explicit system execution attribution without human login"
+ ],
+ "regression": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 198,
+ "passed": 198,
+ "assertions": 1579,
+ "duration_ms": 51432
+ },
+ "final_replenishment": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 18,
+ "passed": 18,
+ "assertions": 155,
+ "duration_ms": 4866
+ },
+ "postgresql": {
+ "checked_at": "2026-11-02T12:40:05+02:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":33,\"passed\":33,\"assertions\":310,\"duration_ms\":25855}",
+ "races": [
+ {
+ "scenario": "same_operation",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_operations",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "abrupt_exit_after_physical_commit_then_two_recovery_workers",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "no_authenticated_human": true,
+ "result": "passed"
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+ },
+ "installation": "replenishment-recovery-install.json",
+ "http": {
+ "checked_at": "2026-11-02T10:40:17.957Z",
+ "environment": "http://127.0.0.1:8005",
+ "component": "stock/replenishment",
+ "recovery_installed": true,
+ "pagination": [
+ "rules",
+ "needs",
+ "history",
+ "workItems"
+ ],
+ "filter": "pending",
+ "scheduler": {
+ "started_at": "2026-11-02T12:39:47+02:00",
+ "finished_at": "2026-11-02T12:39:48+02:00",
+ "pid": 24528,
+ "scope": "SGC evaluation 8005",
+ "result": "passed",
+ "exit_code": 0
+ },
+ "administrator_read_only": true,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "visual_browser_review": false
+ },
+ "runtime": {
+ "observed_passes": 7,
+ "last_pass": {
+ "started_at": "2026-11-02T12:42:50+02:00",
+ "finished_at": "2026-11-02T12:42:51+02:00",
+ "pid": 24528,
+ "scope": "SGC evaluation 8005",
+ "result": "passed",
+ "exit_code": 0
+ },
+ "windows_startup_service_installed": false,
+ "technical_interval_minutes": 5
+ },
+ "checks": {
+ "types": "passed",
+ "common_runtime_sha256_files": 18,
+ "build": "passed",
+ "scoped_php_analysis": "passed",
+ "formatting_and_lint": "passed"
+ },
+ "historical_evidence_preserved_without_rerun": [
+ "assessment 33 tests / 314 assertions",
+ "general 918 passed / 2 skipped"
+ ],
+ "human_acceptance": "prepared script; not performed",
+ "complete_visual_review": false,
+ "sgf_real_installation_changed": false,
+ "persistent_accounts_or_grants_created": false,
+ "restrictions": [
+ "SGF installation remains 0.1.1",
+ "additional synthetic identity proposal remains unauthorized"
+ ],
+ "next": [
+ "warehouse reservation workdesk notices and expressly configured priorities/expiry",
+ "controlled acceptance with existing authorized users"
+ ]
+}
diff --git a/docs/proofs/replenishment-recovery-install.json b/docs/proofs/replenishment-recovery-install.json
new file mode 100644
index 0000000..c11c2ae
--- /dev/null
+++ b/docs/proofs/replenishment-recovery-install.json
@@ -0,0 +1,14 @@
+{
+ "checked_at": "2026-11-02T10:36:06+00:00",
+ "migration": "2026_11_02_000050_create_replenishment_work",
+ "environment": "local evaluation 8005",
+ "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261102-103601\/before.dump",
+ "backup_sha256": "8680431889ff5c9cd157813cb592e45bcc92092de6c7932e3d9bfe64a35af390",
+ "restore_verified": true,
+ "existing_tables_preserved": 110,
+ "new_tables": {
+ "replenishment_work": 0
+ },
+ "accounts_or_grants_changed": false,
+ "domain_installed": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-code-deploy-delivery.json b/docs/proofs/sgf-code-deploy-delivery.json
new file mode 100644
index 0000000..4910118
--- /dev/null
+++ b/docs/proofs/sgf-code-deploy-delivery.json
@@ -0,0 +1,72 @@
+{
+ "passed": true,
+ "checks": [
+ "Host expl\u00edcito obrigat\u00f3rio",
+ "Bundle expl\u00edcito obrigat\u00f3rio",
+ "plan is read-only",
+ "Manifesto divergente",
+ "Cache Laravel personalizado",
+ "manuten\u00e7\u00e3o activa",
+ "servi\u00e7os de escrita parados",
+ "Destino de c\u00f3digo divergente",
+ "applied offline without activation",
+ "invalidates known Laravel caches without touching unrelated files",
+ "applied verified overlay app\/Modules\/Identity\/Services\/Permissions.php",
+ "applied verified overlay app\/Modules\/Identity\/Services\/IdentityWorkflow.php",
+ "applied verified overlay app\/Modules\/Purchasing\/BudgetAdapter.php",
+ "applied verified overlay app\/Modules\/Purchasing\/BudgetWorkspace.php",
+ "applied verified overlay app\/Providers\/AppServiceProvider.php",
+ "applied verified overlay app\/Shared\/Context\/ModuleNavigation.php",
+ "applied verified overlay resources\/js\/app.jsx",
+ "applied verified overlay resources\/js\/PurchasingHome.jsx",
+ "applied verified overlay public\/build\/manifest.json",
+ "apply retry does not duplicate installation",
+ "C\u00f3digo local alterado",
+ "refused rollback preserves bundle and local code",
+ "Cache recriado",
+ "rollback preserves maintenance and never touches data schema",
+ "rollback restores exact previous cache",
+ "restored original overlay app\/Modules\/Identity\/Services\/Permissions.php",
+ "restored original overlay app\/Modules\/Identity\/Services\/IdentityWorkflow.php",
+ "restored original overlay app\/Modules\/Purchasing\/BudgetAdapter.php",
+ "restored original overlay app\/Modules\/Purchasing\/BudgetWorkspace.php",
+ "restored original overlay app\/Providers\/AppServiceProvider.php",
+ "restored original overlay app\/Shared\/Context\/ModuleNavigation.php",
+ "restored original overlay resources\/js\/app.jsx",
+ "restored original overlay resources\/js\/PurchasingHome.jsx",
+ "restored original overlay public\/build\/manifest.json",
+ "withdrawn bundle preserved for diagnosis",
+ "clone remains on original package pointer",
+ "Injected deployment interruption",
+ "interruption retains maintenance",
+ "interrupted deployment recovers app\/Modules\/Identity\/Services\/Permissions.php",
+ "interrupted deployment recovers app\/Modules\/Identity\/Services\/IdentityWorkflow.php",
+ "interrupted deployment recovers app\/Modules\/Purchasing\/BudgetAdapter.php",
+ "interrupted deployment recovers app\/Modules\/Purchasing\/BudgetWorkspace.php",
+ "interrupted deployment recovers app\/Providers\/AppServiceProvider.php",
+ "interrupted deployment recovers app\/Shared\/Context\/ModuleNavigation.php",
+ "interrupted deployment recovers resources\/js\/app.jsx",
+ "interrupted deployment recovers resources\/js\/PurchasingHome.jsx",
+ "interrupted deployment recovers public\/build\/manifest.json",
+ "destination pointer untouched",
+ "Injected post-switch interruption",
+ "post-switch interruption recovers without exposing partial code",
+ "Outra implanta\u00e7\u00e3o em curso",
+ "CLI plan verifies bundle without writes",
+ "CLI apply leaves services and maintenance under operator control",
+ "CLI rollback preserves 0.1.1 pointer"
+ ],
+ "candidate": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20",
+ "clones": [
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-f2382065595e",
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-1de2f989fa65",
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-7dd4d6ad2e94",
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-eeca8efbf821"
+ ],
+ "checked_at": "2026-11-02T07:09:25+00:00",
+ "destination_version": "0.1.1",
+ "destination_applied": false,
+ "old_suites_repeated": false,
+ "database_opened": false,
+ "services_restarted": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-corrected-delivery.json b/docs/proofs/sgf-corrected-delivery.json
new file mode 100644
index 0000000..4e444f6
--- /dev/null
+++ b/docs/proofs/sgf-corrected-delivery.json
@@ -0,0 +1,299 @@
+{
+ "passed": true,
+ "checks": [
+ "reproduced current 87-table schema without source rows",
+ "no accounts or grants imported from destination",
+ "runtime cannot create schema objects",
+ "injected dependency migration failure",
+ "failed migration restores original schema and identity data",
+ "successful migration preserves existing identity and grants",
+ "installation creates empty centres and allocations",
+ "runtime module SQL access denied before test-only privilege plan",
+ "minimal runtime excludes historical update and requisition deletion",
+ "runtime reads corrected revision without ownership",
+ "provider really loaded from isolated copy",
+ "new bundle bootstrap accepted by installer integrity check",
+ "anonymous direct initialisation URL denied",
+ "native clone authentication 1",
+ "native clone context 1",
+ "native clone authentication 2",
+ "native clone context 2",
+ "provider registers initial authority page without prior functional rights",
+ "initial authority CSRF enforced",
+ "initial authority through normal middleware",
+ "work page requires explicit module selection",
+ "new requisition view right makes purchasing selectable",
+ "stale module generation refused",
+ "provider registers operational work page",
+ "different enterprise option refused by operational composition",
+ "native administrative delegation purchasing_requisitions.view",
+ "native administrative delegation purchasing_requisitions.approve.department_head",
+ "native administrative delegation purchasing_budget.view",
+ "native administrative delegation purchasing_budget.approve",
+ "empty installation exposes authorised centre and budget preparation",
+ "centre created through authorised UI API with runtime role",
+ "centre retries preserve one source",
+ "existing budget API prepares synthetic allocation",
+ "budget approval requires independent identity",
+ "independent allocation approval uses existing budget kernel",
+ "workspace dependencies available on reproduced schema",
+ "direct budget movements cannot bypass requisition lifecycle",
+ "operational policy configured via HTTP",
+ "integrated operational draft",
+ "integrated submit uses existing budget adapter",
+ "integrated independent identity enforced",
+ "integrated approval by another identity",
+ "integrated authorised consultation and history",
+ "native administration receives complete unit catalogue",
+ "native administration receives canonical server labels",
+ "reviewer cannot inspect administrative catalogue without management right",
+ "forged delegation of an unowned catalogue right refused",
+ "packaged frontend dependency served before authentication",
+ "packaged frontend dependency served before authentication",
+ "native SGF entry renders the packaged frontend manifest",
+ "undeclared frontend asset refused",
+ "minimal plan removes inherited update and delete on new append-only sources",
+ "runtime cannot truncate requisitions",
+ "existing table ACL preserved a_billing_drafts",
+ "existing table ACL preserved a_clients",
+ "existing table ACL preserved a_commands",
+ "existing table ACL preserved a_events",
+ "existing table ACL preserved a_history",
+ "existing table ACL preserved a_outbox",
+ "existing table ACL preserved a_pending",
+ "existing table ACL preserved a_service_drafts",
+ "existing table ACL preserved a_services",
+ "existing table ACL preserved b_internal_confirmations",
+ "existing table ACL preserved ca_commands",
+ "existing table ACL preserved ca_decisions",
+ "existing table ACL preserved ca_proposals",
+ "existing table ACL preserved ca_resolutions",
+ "existing table ACL preserved ca_submissions",
+ "existing table ACL preserved cache",
+ "existing table ACL preserved cache_locks",
+ "existing table ACL preserved companies",
+ "existing table ACL preserved company_memberships",
+ "existing table ACL preserved dc_calculations",
+ "existing table ACL preserved dc_commands",
+ "existing table ACL preserved dc_decisions",
+ "existing table ACL preserved dc_events",
+ "existing table ACL preserved dc_outbox",
+ "existing table ACL preserved dc_rates",
+ "existing table ACL preserved doc_history",
+ "existing table ACL preserved doc_records",
+ "existing table ACL preserved document_preview_model_history",
+ "existing table ACL preserved document_preview_models",
+ "existing table ACL preserved failed_jobs",
+ "existing table ACL preserved ib_budgets",
+ "existing table ACL preserved ib_commands",
+ "existing table ACL preserved ib_comparisons",
+ "existing table ACL preserved ib_decisions",
+ "existing table ACL preserved ib_events",
+ "existing table ACL preserved ib_outbox",
+ "existing table ACL preserved ic_commands",
+ "existing table ACL preserved ic_decisions",
+ "existing table ACL preserved ic_events",
+ "existing table ACL preserved ic_inputs",
+ "existing table ACL preserved ic_lots",
+ "existing table ACL preserved ic_outbox",
+ "existing table ACL preserved ic_products",
+ "existing table ACL preserved ic_results",
+ "existing table ACL preserved identity_audit",
+ "existing table ACL preserved job_batches",
+ "existing table ACL preserved jobs",
+ "existing table ACL preserved migrations",
+ "existing table ACL preserved password_reset_tokens",
+ "existing table ACL preserved permission_grants",
+ "existing table ACL preserved service_calculation_profiles",
+ "existing table ACL preserved service_calculation_results",
+ "existing table ACL preserved sessions",
+ "existing table ACL preserved sf_activation_events",
+ "existing table ACL preserved sf_decisions",
+ "existing table ACL preserved sf_proposals",
+ "existing table ACL preserved si_commands",
+ "existing table ACL preserved si_correction_requests",
+ "existing table ACL preserved si_counters",
+ "existing table ACL preserved si_decisions",
+ "existing table ACL preserved si_events",
+ "existing table ACL preserved si_invoices",
+ "existing table ACL preserved si_outbox",
+ "existing table ACL preserved si_proposals",
+ "existing table ACL preserved sr_commands",
+ "existing table ACL preserved sr_deliveries",
+ "existing table ACL preserved sr_effects",
+ "existing table ACL preserved sr_events",
+ "existing table ACL preserved sr_outbox",
+ "existing table ACL preserved sr_records",
+ "existing table ACL preserved tenants",
+ "existing table ACL preserved units",
+ "existing table ACL preserved users",
+ "existing table ACL preserved wb_claims",
+ "existing table ACL preserved wb_commands",
+ "existing table ACL preserved wb_decisions",
+ "existing table ACL preserved wb_events",
+ "existing table ACL preserved wb_generations",
+ "existing table ACL preserved wb_outbox",
+ "existing table ACL preserved wb_proposals",
+ "existing table ACL preserved wo_commands",
+ "existing table ACL preserved wo_decisions",
+ "existing table ACL preserved wo_entries",
+ "existing table ACL preserved wo_equipment",
+ "existing table ACL preserved wo_events",
+ "existing table ACL preserved wo_orders",
+ "existing table ACL preserved wo_outbox",
+ "actual runtime CREATE denied",
+ "actual runtime ALTER of identity catalogue denied",
+ "centre creation requires own configuration and budget rights",
+ "centre context cannot be forged",
+ "refused centre operations leave original source intact",
+ "unsupported requisition type refused equipment",
+ "unsupported requisition type refused part",
+ "approved lifecycle covers the three supported basic types",
+ "unsupported types create neither a second request nor reservation",
+ "native revocation applies to direct module URL",
+ "uncontracted module refused at server",
+ "disabled composition exposes no executable work page",
+ "code-only successor installed over existing provenance",
+ "code-only successor rollback restores exact candidate",
+ "code update and rollback preserve every reproduced data table",
+ "code recovery preserves submitted origin decision and reservation",
+ "code recovery leaves baseline and maintenance intact"
+ ],
+ "clone": "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-update-625f1bba6e30",
+ "data_fingerprints": {
+ "a_billing_drafts": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_clients": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_history": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_pending": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_service_drafts": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "a_services": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "b_internal_confirmations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ca_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ca_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ca_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ca_resolutions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ca_submissions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "cache": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "cache_locks": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "cb_audit": "ccf5b17cacb6e9d1cbbebdc55baca6b2b51103f3573c32dd1bddb24a055d2e2b",
+ "cb_budget_consumptions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "cb_budget_entries": "ba73d3b7eb20c25d833df7b3b85f7f1f7575d78365ee1a3c2246e7749f2448ad",
+ "cb_budget_reinforcements": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "cb_budgets": "8df00e7e6b821e2767118c352f58391d4c44bb8df1fcc38a3d40a6a9989543a5",
+ "cb_centres": "45bebc4ca0d417711e2a428b360c343e6dba17a64a4d799639165815e5aaef45",
+ "cb_consumption_sources": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "cb_origins": "f470bed486f1955724e99cde0404445c4b9753320faee2d202289fedffc4d561",
+ "cb_ui_commands": "424c4f2f643087911333a3ecf002bbb5fc5055c5ccf24f5cefd94c9db56b398b",
+ "companies": "e5098861f02b2904e068c604ecbe1379ec6597b39fcdc9575c9d9eaa3b459aec",
+ "company_memberships": "562084217604e0bd153e36375251db9e4e87f92c744c69d38d2a39c7821454e5",
+ "dc_calculations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "dc_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "dc_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "dc_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "dc_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "dc_rates": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "doc_history": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "doc_records": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "document_preview_model_history": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "document_preview_models": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "failed_jobs": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ib_budgets": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ib_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ib_comparisons": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ib_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ib_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ib_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_inputs": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_lots": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_products": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "ic_results": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "identity_audit": "b714264c1c89e456f9217c285169fa91bc379bb744684e39c2028bb48a025ca7",
+ "ir_commands": "fa90f00d334ae11e38a4aea327720569da50f196ad5aa4876fcaa0b6154d78ad",
+ "ir_events": "8379dcb6a4fb3d1cad80cab5a4afe9a49b86a7145e2dc613d6d5371d18e96051",
+ "ir_policies": "7ff4f648cbf36386f78743ed2394eb12e6328fb6e7722c6ba1acb5fab36ca1d1",
+ "ir_policy_versions": "7ff4f648cbf36386f78743ed2394eb12e6328fb6e7722c6ba1acb5fab36ca1d1",
+ "ir_requests": "874bbaa494e41ddc2bee527f3660909123a19c270f7eaee0e19d534d8c355fd6",
+ "ir_steps": "dcde70900864b1e974f66a4778e80e8317907c14a3dc0545afadb468c6f9eef6",
+ "job_batches": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "jobs": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "migrations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "password_reset_tokens": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "permission_grants": "2219ad7945c6cd3d4e28bfc4a06c04eb6141ceaa53097efd01ab5f89811fec13",
+ "service_calculation_profiles": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "service_calculation_results": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sessions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sf_activation_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sf_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sf_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sgc_installations": "75449b61764076870970ddf93e9b987e6b7b24889de47fb8d9046a5345065f1c",
+ "si_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "si_correction_requests": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "si_counters": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "si_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "si_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "si_invoices": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "si_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "si_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sr_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sr_deliveries": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sr_effects": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sr_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sr_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "sr_records": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "tenants": "0487a4884eb77b28bd8c052e9d2bd1a8177bab511463657d326a17c0c77b3619",
+ "units": "c0a43a3bf8907bcbebf727f089e8c75399ed7a94049ad85b666fe6b90851842e",
+ "users": "ea313e4a8b0a16e17dc1b61cea28c6832a76feed78540fe40ab61bbf88c6200c",
+ "wb_claims": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wb_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wb_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wb_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wb_generations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wb_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wb_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wo_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wo_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wo_entries": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wo_equipment": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wo_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wo_orders": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
+ "wo_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
+ },
+ "destination_activated": false,
+ "normal_provider_routes": true,
+ "browser_used": false,
+ "data_recovery": {
+ "verified": true,
+ "tables_reconciled": 103,
+ "approved_origin_reservation_versions_preserved": true,
+ "runtime_read_verified": true,
+ "runtime_ddl_denied": true,
+ "schema_and_triggers_verified": true,
+ "sql_acl_reapplied_only_to_isolated_role": true
+ },
+ "destination_security_and_schema_preserved": true,
+ "artifact": {
+ "directory": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20",
+ "archive": {
+ "file": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidate-archives\/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20.zip",
+ "sha256": "59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703",
+ "manifest_sha256": "a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b"
+ },
+ "installed": false
+ },
+ "source_schema_tables": 87,
+ "source_rows_exported": 0,
+ "source_schema_sha256": "b530e03c759305247f46cdad5013e64a6caae00bded3d9d5ae5b7b5aa06a4647",
+ "runtime_and_migrator_separate": true,
+ "destination_version": "0.1.1",
+ "destination_changed": false,
+ "prior_proofs_reused": true,
+ "checked_at": "2026-11-02T07:10:03+00:00",
+ "test_database_and_roles_removed": true
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-corrected-sql-plan.json b/docs/proofs/sgf-corrected-sql-plan.json
new file mode 100644
index 0000000..f1dbede
--- /dev/null
+++ b/docs/proofs/sgf-corrected-sql-plan.json
@@ -0,0 +1,104 @@
+{
+ "runtime_role": "sgf_dev_app",
+ "confirmation_required": true,
+ "automatically_applied": false,
+ "statements": [
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_centres\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_centres\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"cb_centres\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_centres_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_centres_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"cb_centres_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_origins\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_origins\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"cb_origins\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_origins_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_origins_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"cb_origins_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budgets\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budgets\" FROM PUBLIC",
+ "GRANT SELECT, INSERT, UPDATE ON TABLE public.\"cb_budgets\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budgets_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budgets_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"cb_budgets_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budget_entries\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budget_entries\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"cb_budget_entries\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budget_entries_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budget_entries_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"cb_budget_entries_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_audit\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_audit\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"cb_audit\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_audit_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_audit_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"cb_audit_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_ui_commands\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_ui_commands\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"cb_ui_commands\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_ui_commands_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_ui_commands_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"cb_ui_commands_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budget_consumptions\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budget_consumptions\" FROM PUBLIC",
+ "GRANT SELECT ON TABLE public.\"cb_budget_consumptions\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budget_consumptions_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budget_consumptions_id_seq\" FROM PUBLIC",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budget_reinforcements\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_budget_reinforcements\" FROM PUBLIC",
+ "GRANT SELECT ON TABLE public.\"cb_budget_reinforcements\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budget_reinforcements_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_budget_reinforcements_id_seq\" FROM PUBLIC",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_consumption_sources\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"cb_consumption_sources\" FROM PUBLIC",
+ "GRANT SELECT ON TABLE public.\"cb_consumption_sources\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_consumption_sources_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"cb_consumption_sources_id_seq\" FROM PUBLIC",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_requests\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_requests\" FROM PUBLIC",
+ "GRANT SELECT, INSERT, UPDATE ON TABLE public.\"ir_requests\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_requests_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_requests_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"ir_requests_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_steps\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_steps\" FROM PUBLIC",
+ "GRANT SELECT, INSERT, UPDATE ON TABLE public.\"ir_steps\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_steps_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_steps_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"ir_steps_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_policies\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_policies\" FROM PUBLIC",
+ "GRANT SELECT, INSERT, UPDATE ON TABLE public.\"ir_policies\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_policies_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_policies_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"ir_policies_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_events\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_events\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"ir_events\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_events_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_events_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"ir_events_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_commands\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_commands\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"ir_commands\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_commands_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_commands_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"ir_commands_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_policy_versions\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"ir_policy_versions\" FROM PUBLIC",
+ "GRANT SELECT, INSERT ON TABLE public.\"ir_policy_versions\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_policy_versions_id_seq\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON SEQUENCE public.\"ir_policy_versions_id_seq\" FROM PUBLIC",
+ "GRANT USAGE ON SEQUENCE public.\"ir_policy_versions_id_seq\" TO \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"sgc_installations\" FROM \"sgf_dev_app\"",
+ "REVOKE ALL PRIVILEGES ON TABLE public.\"sgc_installations\" FROM PUBLIC",
+ "GRANT SELECT ON TABLE public.\"sgc_installations\" TO \"sgf_dev_app\""
+ ],
+ "excluded": [
+ "CREATE",
+ "ownership",
+ "DELETE",
+ "UPDATE on append-only history",
+ "all-tables\/default-privileges grants"
+ ]
+}
diff --git a/docs/proofs/sgf-demo-8039-delivery.json b/docs/proofs/sgf-demo-8039-delivery.json
new file mode 100644
index 0000000..9657e3b
--- /dev/null
+++ b/docs/proofs/sgf-demo-8039-delivery.json
@@ -0,0 +1,206 @@
+{
+ "passed": true,
+ "identity": {
+ "db": "sgf_modules_dev_2576427d1939",
+ "role": "sgf_modules_qa_2576427d1939",
+ "cluster": "sgf_modular_regression_2576427d1939",
+ "port": 55439
+ },
+ "scope": "1:1:1",
+ "added_grants": [
+ {
+ "id": 103,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_budget.approve",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 104,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_budget.create",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 105,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_budget.record",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 106,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_budget.view",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 107,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_requisitions.approve.department_head",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 108,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_requisitions.configure",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 109,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_requisitions.prepare",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 110,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_requisitions.view",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 111,
+ "user_id": 2,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_requisitions.view",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 112,
+ "user_id": 2,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_requisitions.approve.department_head",
+ "created_at": "2026-11-02 07:25:53",
+ "updated_at": "2026-11-02 07:25:53"
+ },
+ {
+ "id": 113,
+ "user_id": 2,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_budget.view",
+ "created_at": "2026-11-02 07:25:54",
+ "updated_at": "2026-11-02 07:25:54"
+ },
+ {
+ "id": 114,
+ "user_id": 2,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_budget.approve",
+ "created_at": "2026-11-02 07:25:54",
+ "updated_at": "2026-11-02 07:25:54"
+ },
+ {
+ "id": 115,
+ "user_id": 3,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_requisitions.view",
+ "created_at": "2026-11-02 07:25:54",
+ "updated_at": "2026-11-02 07:25:54"
+ },
+ {
+ "id": 116,
+ "user_id": 3,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_budget.view",
+ "created_at": "2026-11-02 07:25:54",
+ "updated_at": "2026-11-02 07:25:54"
+ }
+ ],
+ "existing_grants_preserved": true,
+ "existing_accounts_preserved": true,
+ "single_approved_request": true,
+ "single_reservation": true,
+ "reservation": {
+ "id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "budget_id": 1,
+ "requisition_id": 1,
+ "created_by": 1,
+ "kind": "reserve",
+ "reserved_minor": 10000,
+ "committed_minor": 0,
+ "created_at": "2026-11-02 07:26:06"
+ },
+ "budget": {
+ "id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "cost_center_id": 1,
+ "year": 2026,
+ "kind": "operating",
+ "amount_minor": 1000000,
+ "includes_tax": true,
+ "reason": "Owner-authorised synthetic 10000 MZN allocation for 2026.",
+ "created_by": 1,
+ "approved_by": 2,
+ "approved_at": "2026-11-02 07:26:05",
+ "created_at": "2026-11-02 07:26:04",
+ "updated_at": "2026-11-02 07:26:05"
+ },
+ "decision": {
+ "id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "request_id": 1,
+ "position": 1,
+ "role": "department_head",
+ "decision": "approved",
+ "approver_id": 2,
+ "comment": null,
+ "decided_at": "2026-11-02 07:26:19"
+ },
+ "pointer_preserved": true,
+ "backup_sha256": "2eed6d9f775d8d5dc8c7b1606e230b8782c09a69098d31e659d9ad28a4d608fa",
+ "recovery_directory": "C:\/laragon\/data\/sgf-tempo\/run\/modular-regression-2576427d1939\/private\/requisitions-recovery",
+ "checked_at": "2026-11-02T07:27:43+00:00",
+ "owner_runtime_limitation": true,
+ "visual_review": false,
+ "read_only_record_visibility_limitation": true
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-demo-8039-http.json b/docs/proofs/sgf-demo-8039-http.json
new file mode 100644
index 0000000..2c49c0f
--- /dev/null
+++ b/docs/proofs/sgf-demo-8039-http.json
@@ -0,0 +1,41 @@
+{
+ "passed": true,
+ "url": "http://127.0.0.1:8039",
+ "scope": "1:1:1",
+ "rights": {
+ "1": [
+ "purchasing_requisitions.view",
+ "purchasing_requisitions.prepare",
+ "purchasing_requisitions.configure",
+ "purchasing_requisitions.approve.department_head",
+ "purchasing_budget.view",
+ "purchasing_budget.create",
+ "purchasing_budget.approve",
+ "purchasing_budget.record"
+ ],
+ "2": [
+ "purchasing_requisitions.view",
+ "purchasing_requisitions.approve.department_head",
+ "purchasing_budget.view",
+ "purchasing_budget.approve"
+ ],
+ "3": [
+ "purchasing_requisitions.view",
+ "purchasing_budget.view"
+ ]
+ },
+ "centre_id": 1,
+ "budget_id": 1,
+ "request_id": 1,
+ "checks": [
+ "Existing three synthetic identities authenticate without password changes",
+ "Eight plus four plus two explicit authorised grants",
+ "Synthetic allocation 10000 MZN approved by second identity; self and read-only approvals refused",
+ "Full request lifecycle, independent approval and three-event history consulted by preparer and reviewer",
+ "Existing record visibility rule returns 404 to read-only third identity; listing remains accessible; no extra rights granted",
+ "Read-only profile refused centre, budget approval, policy, preparation, submission, decision and access administration"
+ ],
+ "checked_at": "2026-11-02T07:26:58.680Z",
+ "visual_review": false,
+ "real_installation_changed": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-installation-decision-inventory.json b/docs/proofs/sgf-installation-decision-inventory.json
new file mode 100644
index 0000000..e431122
--- /dev/null
+++ b/docs/proofs/sgf-installation-decision-inventory.json
@@ -0,0 +1,81 @@
+{
+ "checked_at": "2026-11-02T06:43:39+00:00",
+ "host": "C:\\laragon\\www\\sgf-tempo",
+ "app_url": "http://127.0.0.1:8000",
+ "environment": "local",
+ "php": "8.4.27",
+ "database": {
+ "driver": "pgsql",
+ "configured_host": "127.0.0.1",
+ "configured_port": "55432",
+ "configured_database": "sgf_tempo_dev",
+ "actual": {
+ "database": "sgf_tempo_dev",
+ "address": "127.0.0.1/32",
+ "port": 55432
+ }
+ },
+ "schema": {
+ "public_table_count": 87,
+ "missing_foundation": [
+
+ ],
+ "existing_budget_tables": [
+
+ ],
+ "existing_integration_tables": [
+
+ ],
+ "budget_ui_support_tables_present": [
+
+ ],
+ "permission_column_length": 40,
+ "scope_constraint_present": true,
+ "scope_constraint_sha256": "25044df129dc69efe8e9411c6b2f82558982cc37da7b478b771d1e58842025e0",
+ "ddl_privileges": {
+ "can_create": false,
+ "can_alter_permissions": false
+ }
+ },
+ "database_roles": {
+ "runtime_role": "sgf_dev_app",
+ "permissions_owner": "sgf_dev_owner"
+ },
+ "config_cached": false,
+ "owner_capabilities": {
+ "can_login": true,
+ "can_create_public": true
+ },
+ "preservation_baseline": {
+ "permission_grants": {
+ "rows": 89,
+ "sha256": "714dfbbbed4a19c1d3e10077b020ec6d06334cbc2b538820808f73d06593bd59"
+ },
+ "company_memberships": {
+ "rows": 2,
+ "sha256": "562084217604e0bd153e36375251db9e4e87f92c744c69d38d2a39c7821454e5"
+ },
+ "identity_audit": {
+ "rows": 162,
+ "sha256": "6d05de68554ab9a06d8c6a8132bb3f1ff99e19d06ed6294353b40e6084a9e4b1"
+ }
+ },
+ "organisation_counts": {
+ "companies": 1,
+ "units": 1,
+ "company_admin_grants": 1
+ },
+ "purchasing_pointer": {
+ "version": "0.1.1",
+ "manifest_sha256": "91ba35ee3aa1ca226052c94ed682e430cb7870beb241f16b17d889046e1f711d",
+ "source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250",
+ "previous": "0.1.0",
+ "audit_id": "8d922d5b757723be97f7c5f7"
+ },
+ "pointer_sha256": "81899e531e9eec449717e694d8bdc106a9e220a828f5491ce918c1d2dc612787",
+ "bundle_exists": false,
+ "maintenance_marker_present": false,
+ "operational_config_file_present": false,
+ "read_only_transaction": true,
+ "installed": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-read-8039-data.json b/docs/proofs/sgf-read-8039-data.json
new file mode 100644
index 0000000..45aa1a1
--- /dev/null
+++ b/docs/proofs/sgf-read-8039-data.json
@@ -0,0 +1,21 @@
+{
+ "passed": true,
+ "identity": {
+ "db": "sgf_modules_dev_2576427d1939",
+ "role": "sgf_modules_qa_2576427d1939",
+ "port": 55439,
+ "cluster": "sgf_modular_regression_2576427d1939"
+ },
+ "data_tables_reconciled": 109,
+ "audit_appended_only": true,
+ "request_id": 1,
+ "request_version": 3,
+ "amount_minor": 10000,
+ "reservation_count": 1,
+ "rights_and_accounts_unchanged": true,
+ "config_unchanged": true,
+ "pointer_unchanged": true,
+ "candidate_manifest_sha256": "166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de",
+ "backup_sha256": "e07756b650d44361c0b2fce0eb170d0b54e7753b61c624a21b2524104e397055",
+ "checked_at": "2026-11-02T07:55:35+00:00"
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-read-8039-delivery.json b/docs/proofs/sgf-read-8039-delivery.json
new file mode 100644
index 0000000..41ce61e
--- /dev/null
+++ b/docs/proofs/sgf-read-8039-delivery.json
@@ -0,0 +1,52 @@
+{
+ "passed": true,
+ "scope": "8039 / 55439 / 1:1:1",
+ "candidate": {
+ "directory": "C:\\laragon\\www\\compras/storage/app/private/candidates/OPERATIONAL-CANDIDATE-166e397e3ca63f02",
+ "archive": {
+ "file": "C:\\laragon\\www\\compras/storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-166e397e3ca63f02.zip",
+ "sha256": "620660d76329c800f3c6b18a521c03ccdadce09502d2a7744e1e91b77560f785",
+ "manifest_sha256": "166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de"
+ },
+ "prior_manifest_sha256": "a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b",
+ "changed_files": [
+ "integrations/sgf/internal-requisitions/Port.php"
+ ],
+ "schema_changed": false,
+ "rights_changed": false,
+ "installed_real": false
+ },
+ "initial_update": {
+ "deployment_id": "166e397e3ca63f02d3dc5765",
+ "state": "applied",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "configuration_changed": false,
+ "activation_performed": false
+ },
+ "rollback": {
+ "deployment_id": "166e397e3ca63f02d3dc5765",
+ "state": "rolled-back",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "data_schema_changed": false
+ },
+ "final_update": {
+ "deployment_id": "3afbba5e3ed60b853d62b869",
+ "state": "applied",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "configuration_changed": false,
+ "activation_performed": false
+ },
+ "http_proof": "sgf-read-8039-http.json",
+ "previous_version_proof": "sgf-read-8039-rollback.json",
+ "data_proof": "sgf-read-8039-data.json",
+ "schema_changed": false,
+ "sql_grants_changed": false,
+ "accounts_or_rights_changed": false,
+ "request_recreated": false,
+ "visual_review": false,
+ "real_installation_changed": false,
+ "prior_proofs_reused": true
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-read-8039-http.json b/docs/proofs/sgf-read-8039-http.json
new file mode 100644
index 0000000..0ac979f
--- /dev/null
+++ b/docs/proofs/sgf-read-8039-http.json
@@ -0,0 +1,19 @@
+{
+ "passed": true,
+ "mode": "corrected-version",
+ "url": "http://127.0.0.1:8039",
+ "scope": "1:1:1",
+ "request_id": 1,
+ "request_recreated": false,
+ "reservation_commands_executed": false,
+ "rights_changed": false,
+ "checks": [
+ "preparador: existing request 1 listed, detail and three-event history readable",
+ "revisor: existing request 1 listed, detail and three-event history readable",
+ "consulta: existing request 1 listed, detail and three-event history readable",
+ "Consulta: no allowed actions; creation, save, submission, decision, policy, centre, budget approval and administration refused",
+ "Consulta: forged context, wrong option, missing record and unauthorised company/unit refused"
+ ],
+ "checked_at": "2026-11-02T07:55:34.002Z",
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-read-8039-rollback.json b/docs/proofs/sgf-read-8039-rollback.json
new file mode 100644
index 0000000..5ff29ed
--- /dev/null
+++ b/docs/proofs/sgf-read-8039-rollback.json
@@ -0,0 +1,17 @@
+{
+ "passed": true,
+ "mode": "previous-version-recovery",
+ "url": "http://127.0.0.1:8039",
+ "scope": "1:1:1",
+ "request_id": 1,
+ "request_recreated": false,
+ "reservation_commands_executed": false,
+ "rights_changed": false,
+ "checks": [
+ "preparador: existing request 1 listed, detail and three-event history readable",
+ "revisor: existing request 1 listed, detail and three-event history readable",
+ "Previous version reproduces missing read-only listing/detail"
+ ],
+ "checked_at": "2026-11-02T07:55:20.187Z",
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-read-candidate.json b/docs/proofs/sgf-read-candidate.json
new file mode 100644
index 0000000..5f64758
--- /dev/null
+++ b/docs/proofs/sgf-read-candidate.json
@@ -0,0 +1,15 @@
+{
+ "directory": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-166e397e3ca63f02",
+ "archive": {
+ "file": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidate-archives\/OPERATIONAL-CANDIDATE-166e397e3ca63f02.zip",
+ "sha256": "620660d76329c800f3c6b18a521c03ccdadce09502d2a7744e1e91b77560f785",
+ "manifest_sha256": "166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de"
+ },
+ "prior_manifest_sha256": "a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b",
+ "changed_files": [
+ "integrations\/sgf\/internal-requisitions\/Port.php"
+ ],
+ "schema_changed": false,
+ "rights_changed": false,
+ "installed_real": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-sourcing-candidate.json b/docs/proofs/sgf-sourcing-candidate.json
new file mode 100644
index 0000000..e7ee111
--- /dev/null
+++ b/docs/proofs/sgf-sourcing-candidate.json
@@ -0,0 +1,11 @@
+{
+ "directory": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-040fb0cb67a77d61",
+ "archive": {
+ "file": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidate-archives\/OPERATIONAL-CANDIDATE-040fb0cb67a77d61.zip",
+ "sha256": "dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a",
+ "manifest_sha256": "040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58"
+ },
+ "prior_manifest_sha256": "166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de",
+ "schema_extension": "sourcing revision 1; four ss_* tables and explicit catalogue extension",
+ "real_activated": false
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-sourcing-core.json b/docs/proofs/sgf-sourcing-core.json
new file mode 100644
index 0000000..90c940c
--- /dev/null
+++ b/docs/proofs/sgf-sourcing-core.json
@@ -0,0 +1,20 @@
+{
+ "passed": true,
+ "checks": [
+ "Existing minimum independent evaluations retained",
+ "Requester cannot evaluate",
+ "Existing weighted scoring identifies best eligible quote",
+ "Requester cannot propose award",
+ "Proposer cannot confirm own award",
+ "Requester cannot confirm award",
+ "Quote revision invalidates existing award basis and evaluations",
+ "Evaluator revocation invalidates confirmation basis",
+ "Expired quote invalidates confirmation",
+ "Independent confirmation preserves exact proposed comparison",
+ "Confirmed process cannot be rewritten",
+ "Comparison snapshot and justification preserved"
+ ],
+ "synthetic_only": true,
+ "persistent_demo_accounts_created": false,
+ "checked_at": "2026-11-02T09:45:33+00:00"
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-sourcing-delivery.json b/docs/proofs/sgf-sourcing-delivery.json
new file mode 100644
index 0000000..f70a324
--- /dev/null
+++ b/docs/proofs/sgf-sourcing-delivery.json
@@ -0,0 +1,56 @@
+{
+ "candidate": {
+ "directory": "C:\\laragon\\www\\compras/storage/app/private/candidates/OPERATIONAL-CANDIDATE-040fb0cb67a77d61",
+ "archive": {
+ "file": "C:\\laragon\\www\\compras/storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-040fb0cb67a77d61.zip",
+ "sha256": "dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a",
+ "manifest_sha256": "040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58"
+ },
+ "prior_manifest_sha256": "166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de",
+ "schema_extension": "sourcing revision 1; four ss_* tables and explicit catalogue extension",
+ "real_activated": false
+ },
+ "changed_files": [
+ "integrations/sgf/access/AccessOverlay.php",
+ "integrations/sgf/internal-requisitions/Controller.php",
+ "integrations/sgf/internal-requisitions/screen.mjs",
+ "integrations/sgf/operational/CodeDeployment.php",
+ "integrations/sgf/operational/Installation.php",
+ "integrations/sgf/operational/routes.php",
+ "bootstrap.php",
+ "app/Modules/Identity/Services/Permissions.php",
+ "integrations/sgf/sourcing/bootstrap.php",
+ "integrations/sgf/sourcing/Controller.php",
+ "integrations/sgf/sourcing/routes.php",
+ "integrations/sgf/sourcing/Schema.php",
+ "integrations/sgf/sourcing/screen.mjs",
+ "integrations/sgf/sourcing/screen.php",
+ "integrations/sgf/sourcing/Workspace.php",
+ "packages/purchasing/src/Sourcing.php",
+ "scripts/sgf-sourcing-install.php",
+ "integrations/sgf/sourcing/comparison-defaults.json"
+ ],
+ "core_proof": "sgf-sourcing-core.json",
+ "isolated_proof": "sgf-sourcing-isolated.json",
+ "demo_proof": "sgf-sourcing-demo.json",
+ "demo_http_proof": "sgf-sourcing-demo-http.json",
+ "final_code_update": {
+ "deployment_id": "040fb0cb67a77d618011bc7a",
+ "state": "applied",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "configuration_changed": false,
+ "activation_performed": false
+ },
+ "implemented_and_tested_in_isolation": true,
+ "demo_award_completed": false,
+ "fourth_persistent_account_created": false,
+ "owner_declined_fourth_account": true,
+ "new_authorised_grants_count": 3,
+ "sql_grant_revoke_executed": false,
+ "real_pointer_sha256": "81899e531e9eec449717e694d8bdc106a9e220a828f5491ce918c1d2dc612787",
+ "real_pointer_preserved": true,
+ "real_version": "0.1.1",
+ "visual_review": false,
+ "prior_proofs_reused": true
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-sourcing-demo-http.json b/docs/proofs/sgf-sourcing-demo-http.json
new file mode 100644
index 0000000..b5b4498
--- /dev/null
+++ b/docs/proofs/sgf-sourcing-demo-http.json
@@ -0,0 +1,19 @@
+{
+ "passed": true,
+ "url": "http://127.0.0.1:8039",
+ "scope": "1:1:1",
+ "request_id": 1,
+ "checks": [
+ "Existing request reused with version 3 and unchanged three-event history",
+ "Three selected synthetic suppliers, three quotes and one eligible evaluation per quote",
+ "Comparison and award proposal remain blocked without second eligible evaluator",
+ "Quoted amount differing from reserved amount is signalled without a budget movement",
+ "Consulta retains full sourcing read and no preparation/evaluation/proposal/confirmation",
+ "No persistent identity has award-confirmation right; no award created",
+ "Protected sourcing screen is accessible to all three existing profiles"
+ ],
+ "confirmation_completed": false,
+ "owner_decision_no_fourth_account": true,
+ "visual_review": false,
+ "checked_at": "2026-11-02T09:45:33.016Z"
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-sourcing-demo.json b/docs/proofs/sgf-sourcing-demo.json
new file mode 100644
index 0000000..9ea35ac
--- /dev/null
+++ b/docs/proofs/sgf-sourcing-demo.json
@@ -0,0 +1,64 @@
+{
+ "passed": true,
+ "identity": {
+ "db": "sgf_modules_dev_2576427d1939",
+ "role": "sgf_modules_qa_2576427d1939",
+ "port": 55439,
+ "cluster": "sgf_modular_regression_2576427d1939"
+ },
+ "scope": "1:1:1",
+ "candidate": {
+ "directory": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-040fb0cb67a77d61",
+ "archive": {
+ "file": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidate-archives\/OPERATIONAL-CANDIDATE-040fb0cb67a77d61.zip",
+ "sha256": "dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a",
+ "manifest_sha256": "040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58"
+ },
+ "prior_manifest_sha256": "166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de",
+ "schema_extension": "sourcing revision 1; four ss_* tables and explicit catalogue extension",
+ "real_activated": false
+ },
+ "protected_users_requisition_reservation_consumption_unchanged": true,
+ "previous_grants_preserved": true,
+ "new_grants": [
+ {
+ "id": 141,
+ "user_id": 1,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_sourcing.prepare",
+ "created_at": "2026-11-02 09:09:01",
+ "updated_at": "2026-11-02 09:09:01"
+ },
+ {
+ "id": 142,
+ "user_id": 2,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_sourcing.evaluate",
+ "created_at": "2026-11-02 09:09:01",
+ "updated_at": "2026-11-02 09:09:01"
+ },
+ {
+ "id": 143,
+ "user_id": 2,
+ "tenant_id": 1,
+ "company_id": 1,
+ "unit_id": 1,
+ "permission": "purchasing_sourcing.propose",
+ "created_at": "2026-11-02 09:09:01",
+ "updated_at": "2026-11-02 09:09:01"
+ }
+ ],
+ "persistent_accounts_created": false,
+ "proposals": 0,
+ "confirmed": false,
+ "suppliers_selected": 3,
+ "quotations": 3,
+ "pending": "Second eligible evaluator and independent confirmer absent; no account created by owner decision.",
+ "backup_sha256": "cb9d55db5fee08cb36e3af4c962556b465be9e14071b5c3505c834d2ef4d7bcd",
+ "recovery_directory": "C:\/laragon\/data\/sgf-tempo\/run\/modular-regression-2576427d1939\/private\/sourcing-recovery",
+ "checked_at": "2026-11-02T09:54:58+00:00"
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-sourcing-initial-isolated.json b/docs/proofs/sgf-sourcing-initial-isolated.json
new file mode 100644
index 0000000..423ad52
--- /dev/null
+++ b/docs/proofs/sgf-sourcing-initial-isolated.json
@@ -0,0 +1,71 @@
+{
+ "passed": true,
+ "database": "sgf_sourcing_test_305bd6827f3d",
+ "work": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/sourcing-305bd6827f3d",
+ "setup": {
+ "passed": true,
+ "checks": [
+ "Injected migration failure surfaced",
+ "Migration failure rolls back tables and catalogue",
+ "Additive migration accepted",
+ "Migration replay is stable",
+ "Schema-changing update refuses missing migration receipt",
+ "Packaged migration CLI produces exact receipt without activation or grants"
+ ],
+ "fixture_user_only_in_disposable_copy": true,
+ "deployment": {
+ "deployment_id": "7cf6250a6e8d87add3b096ff",
+ "state": "applied",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "configuration_changed": false,
+ "activation_performed": false
+ }
+ },
+ "http": {
+ "passed": true,
+ "checks": [
+ "Source request reused; three suppliers and quotes; consultation is read-only; context and direct access protected",
+ "Concurrent identical commands return one committed version",
+ "Concurrent distinct commands use compare-and-swap; stale version refused",
+ "Proposal retry is idempotent",
+ "Changed quote blocks stale confirmation and invalidates evaluations",
+ "Revocation is effective for existing sessions, replay and qualified evaluations",
+ "Injected failure after event rolls back state, event and receipt",
+ "Concurrent confirmations produce one immutable independent award",
+ "Independent confirmation references exact comparison and final state is immutable",
+ "Accepted requisition read path remains intact",
+ "Authenticated read-only screen and module script served through protected routes"
+ ],
+ "request_id": 1,
+ "confirmed_only_in_isolated_copy": true,
+ "account_fixture_only_in_copy": true
+ },
+ "recovery": {
+ "passed": true,
+ "checks": [
+ "Origin request unchanged",
+ "Origin budget unchanged",
+ "Origin consumption unchanged",
+ "Exactly one sourcing process",
+ "One event per committed command",
+ "Independent award confirmed in isolated copy",
+ "Database rejects event tampering",
+ "Code rollback preserves award data"
+ ],
+ "code_recovery": {
+ "deployment_id": "7cf6250a6e8d87add3b096ff",
+ "state": "rolled-back",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "data_schema_changed": false
+ },
+ "old_code_requires_schema_recovery": true,
+ "data_schema_restore_verified": true,
+ "pre_recovery_state_archived": true,
+ "restored_requests_reservations_accounts_grants_match": true
+ },
+ "persistent_demo_unchanged": true,
+ "synthetic_copy_only": true,
+ "checked_at": "2026-11-02T09:08:34.188Z"
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-sourcing-isolated.json b/docs/proofs/sgf-sourcing-isolated.json
new file mode 100644
index 0000000..026ef9f
--- /dev/null
+++ b/docs/proofs/sgf-sourcing-isolated.json
@@ -0,0 +1,71 @@
+{
+ "passed": true,
+ "database": "sgf_sourcing_test_58750b506dff",
+ "work": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/sourcing-58750b506dff",
+ "setup": {
+ "passed": true,
+ "checks": [
+ "Injected migration failure surfaced",
+ "Migration failure rolls back tables and catalogue",
+ "Additive migration accepted",
+ "Migration replay is stable",
+ "Schema-changing update refuses missing migration receipt",
+ "Packaged migration CLI produces exact receipt without activation or grants"
+ ],
+ "fixture_user_only_in_disposable_copy": true,
+ "deployment": {
+ "deployment_id": "040fb0cb67a77d618011bc7a",
+ "state": "applied",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "configuration_changed": false,
+ "activation_performed": false
+ }
+ },
+ "http": {
+ "passed": true,
+ "checks": [
+ "Source request reused; three suppliers and quotes; consultation is read-only; context and direct access protected",
+ "Concurrent identical commands return one committed version",
+ "Concurrent distinct commands use compare-and-swap; stale version refused",
+ "Proposal retry is idempotent",
+ "Changed quote blocks stale confirmation and invalidates evaluations",
+ "Revocation is effective for existing sessions, replay and qualified evaluations",
+ "Injected failure after event rolls back state, event and receipt",
+ "Concurrent confirmations produce one immutable independent award",
+ "Independent confirmation references exact comparison and final state is immutable",
+ "Accepted requisition read path remains intact",
+ "Authenticated read-only screen and module script served through protected routes"
+ ],
+ "request_id": 1,
+ "confirmed_only_in_isolated_copy": true,
+ "account_fixture_only_in_copy": true
+ },
+ "recovery": {
+ "passed": true,
+ "checks": [
+ "Origin request unchanged",
+ "Origin budget unchanged",
+ "Origin consumption unchanged",
+ "Exactly one sourcing process",
+ "One event per committed command",
+ "Independent award confirmed in isolated copy",
+ "Database rejects event tampering",
+ "Code rollback preserves award data"
+ ],
+ "code_recovery": {
+ "deployment_id": "040fb0cb67a77d618011bc7a",
+ "state": "rolled-back",
+ "restart_required": true,
+ "maintenance_preserved": true,
+ "data_schema_changed": false
+ },
+ "old_code_requires_schema_recovery": true,
+ "data_schema_restore_verified": true,
+ "pre_recovery_state_archived": true,
+ "restored_requests_reservations_accounts_grants_match": true
+ },
+ "persistent_demo_unchanged": true,
+ "synthetic_copy_only": true,
+ "checked_at": "2026-11-02T09:39:55.200Z"
+}
\ No newline at end of file
diff --git a/docs/proofs/sgf-update-cache-delivery.json b/docs/proofs/sgf-update-cache-delivery.json
new file mode 100644
index 0000000..b893651
--- /dev/null
+++ b/docs/proofs/sgf-update-cache-delivery.json
@@ -0,0 +1,57 @@
+{
+ "passed": true,
+ "checks": [
+ "portable review ZIP matches approved bundle and manifest",
+ "sem proveni\u00eancia",
+ "Transi\u00e7\u00e3o de schema",
+ "plan binds update to active installation",
+ "new version applied without source connection",
+ "previous version preserved locally",
+ "fora de ordem",
+ "vers\u00e3o activa",
+ "C\u00f3digo aplicado alterado",
+ "Conte\u00fado extra",
+ "rollback restores previous bundle exactly",
+ "rollback restores active provenance",
+ "new attempt preserves rolled-back journal",
+ "Injected update switch failure",
+ "interrupted transition recovers previous version",
+ "reapply creates a separate audited attempt",
+ "complete rollback restores original host composition",
+ "native Laravel config route and event caches built",
+ "version update invalidates previous native caches",
+ "Injected cache compilation interruption",
+ "partial native cache build is journalled under maintenance",
+ "cache CLI recovers interrupted native compilation",
+ "invalid partial config cache cannot block native recovery",
+ "cached deployment remains in native maintenance",
+ "cached module route survives restart and denies anonymous access",
+ "Cache mudou ap\u00f3s compila\u00e7\u00e3o",
+ "rollback invalidates registered rebuilt caches",
+ "repeated rollback preserves previous-version rebuilt caches",
+ "previous version restarts with native caches and maintenance",
+ "restarted baseline exposes no removed module route",
+ "baseline starts after cached deployment rollback",
+ "destination remains untouched on 0.1.1"
+ ],
+ "old_candidate": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-10dce6cd57fa1f31",
+ "new_candidate": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba",
+ "archive": {
+ "file": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidate-archives\/OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba.zip",
+ "sha256": "d14a3261d5aaab80f96250af781c700ab53376021f4638e3aee43c15ff37ceed",
+ "manifest_sha256": "b1ec47f7a9ad87e0b232bac41e402d4a00ee5f03f00a79b93effc692ce8b39a2"
+ },
+ "copies": [
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-update-46398e665d32",
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-update-21bc02db1fe2",
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-update-d88b6daafc0a",
+ "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-update-schema-2df473386fc1"
+ ],
+ "checked_at": "2026-11-02T06:32:56+00:00",
+ "destination_version": "0.1.1",
+ "destination_applied": false,
+ "prior_suites_repeated": false,
+ "actual_destination_services_touched": false,
+ "native_caches_built": true,
+ "isolated_server_restarts_verified": true
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-drafts-pg.json b/docs/proofs/warehouse-drafts-pg.json
index b4ba55c..38349c8 100644
--- a/docs/proofs/warehouse-drafts-pg.json
+++ b/docs/proofs/warehouse-drafts-pg.json
@@ -1,7 +1,7 @@
{
- "checked_at": "2026-11-01T16:29:30+00:00",
+ "checked_at": "2026-11-02T06:24:01+00:00",
"database": "disposable PostgreSQL",
- "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":12,\"passed\":12,\"assertions\":122,\"duration_ms\":6270}",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":24,\"passed\":24,\"assertions\":232,\"duration_ms\":20682}",
"races": [
{
"scenario": "same_operation",
@@ -12,6 +12,12 @@
"scenario": "different_operations",
"real_lock_contention": true,
"result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
}
],
"persistent_data_changed": false,
diff --git a/docs/proofs/warehouse-exchange-http.json b/docs/proofs/warehouse-exchange-http.json
new file mode 100644
index 0000000..2540963
--- /dev/null
+++ b/docs/proofs/warehouse-exchange-http.json
@@ -0,0 +1,12 @@
+{
+ "checked_at": "2026-11-02T12:40:19.981Z",
+ "environment": "http://127.0.0.1:8005",
+ "result": "passed",
+ "cursor_roundtrip": true,
+ "limit_validated": true,
+ "observed_first_page_items": 0,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-exchange-pg.json b/docs/proofs/warehouse-exchange-pg.json
new file mode 100644
index 0000000..3ce728c
--- /dev/null
+++ b/docs/proofs/warehouse-exchange-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T12:23:42+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":10,\"passed\":10,\"assertions\":83,\"duration_ms\":11967}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-exchange.json b/docs/proofs/warehouse-exchange.json
new file mode 100644
index 0000000..e6695dd
--- /dev/null
+++ b/docs/proofs/warehouse-exchange.json
@@ -0,0 +1,31 @@
+{
+ "checked_at": "2026-11-02T12:25:08.1678651Z",
+ "module": "standalone SGC warehouse fulfilment read interface",
+ "endpoint": "/stock/integracao/entregas",
+ "contract": "integrations/contracts/warehouse-fulfilment-v1.json",
+ "regression": {
+ "tests": 41,
+ "assertions": 523,
+ "result": "passed",
+ "log": "storage/logs/warehouse-exchange-regression.txt"
+ },
+ "disposable_postgresql": {
+ "tests": 10,
+ "proof": "warehouse-exchange-pg.json",
+ "assertions": 83,
+ "result": "passed"
+ },
+ "http": "warehouse-exchange-http.json",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "contract_json_syntax": "passed",
+ "external_schema_validator": false,
+ "new_frontend_build": false,
+ "full_suite_rerun": false,
+ "visual_review": false,
+ "persistent_business_or_access_changes": false,
+ "new_migration": false,
+ "sgf_installation_changed": false,
+ "remote_connector_implemented": false
+}
diff --git a/docs/proofs/warehouse-http-source-http.json b/docs/proofs/warehouse-http-source-http.json
new file mode 100644
index 0000000..e2f50e0
--- /dev/null
+++ b/docs/proofs/warehouse-http-source-http.json
@@ -0,0 +1,18 @@
+{
+ "checked_at": "2026-11-02T14:27:55.972Z",
+ "environment": "http://127.0.0.1:8005",
+ "result": "passed",
+ "transport": "native PHP cURL to existing QA session",
+ "pages": 1,
+ "received_facts": 0,
+ "receiver_checkpoint_version": 1,
+ "cursor_roundtrip": true,
+ "unauthenticated_read_denied": true,
+ "receiver": "disposable SQLite memory",
+ "persistent_business_commands": false,
+ "persistent_accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "credentials_recorded": false,
+ "technical_credential_issued": false,
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-http-source-pg.json b/docs/proofs/warehouse-http-source-pg.json
new file mode 100644
index 0000000..0cdfd20
--- /dev/null
+++ b/docs/proofs/warehouse-http-source-pg.json
@@ -0,0 +1,29 @@
+{
+ "checked_at": "2026-11-02T14:26:48+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":44,\"passed\":44,\"assertions\":306,\"duration_ms\":15985}",
+ "races": [
+ {
+ "scenario": "same_page",
+ "real_lock_contention": true,
+ "facts": 1,
+ "checkpoint_version": 2,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_pages",
+ "real_lock_contention": true,
+ "facts": 1,
+ "checkpoint_version": 2,
+ "result": "passed"
+ },
+ {
+ "scenario": "process_exit_after_commit_before_acknowledgement",
+ "result": "passed",
+ "facts": 1,
+ "checkpoint_version": 2
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-http-source.json b/docs/proofs/warehouse-http-source.json
new file mode 100644
index 0000000..6f7ab25
--- /dev/null
+++ b/docs/proofs/warehouse-http-source.json
@@ -0,0 +1,41 @@
+{
+ "checked_at": "2026-11-02T14:30:00.2561262Z",
+ "module": "authenticated warehouse HTTP session client",
+ "regression": {
+ "result": "passed",
+ "assertions": 746,
+ "log": "storage/logs/warehouse-http-source-regression.txt",
+ "tests": 75
+ },
+ "disposable_postgresql": {
+ "real_lock_races": 2,
+ "tests": 44,
+ "process_exit_after_commit_recovered": true,
+ "proof": "warehouse-http-source-pg.json",
+ "result": "passed",
+ "assertions": 306
+ },
+ "http": "warehouse-http-source-http.json",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "source_sha256": {
+ "packages/integration/bootstrap.php": "ee68ac5052dd2c11ce7d81ee8c0b01d2ec3a9e6898ff129775a8f13dd38c70ed",
+ "packages/integration/src/WarehouseHttpSource.php": "b56d71e82fe026914d569b6f90e12f06085337e4eeb6e1dc7ee4c51c1b3a3fe8",
+ "packages/integration/src/WarehouseTransportFailure.php": "22b0cf2a00e550c8f0c78a5c9fb45d723a9f6a2f9db5e2fa08c9f7c0eae6fc8e",
+ "packages/integration/src/WarehouseFeed.php": "7252e93168460964907038ea979e1932d605cb935be46d4f119e4f974d8e13fc",
+ "packages/integration/src/WarehouseInbox.php": "5c45508ebb9050266aa8d8a5ee9119ce446fd1eecea71aa50bb354369637a034",
+ "packages/integration/src/WarehousePull.php": "8dcf20c6464e9cb6da712eb1d9fba89dd8e82b6218cd41c71be1b33913a4f3e3"
+ },
+ "remote_session_transport_implemented": true,
+ "permanent_technical_authentication_implemented": false,
+ "host_configuration_ui_implemented": false,
+ "host_business_projection_implemented": false,
+ "persistent_receiver_schema_installed": false,
+ "persistent_business_accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "commercial_tls_connection_tested": false,
+ "full_suite_rerun": false,
+ "frontend_build": false,
+ "visual_review": false
+}
diff --git a/docs/proofs/warehouse-inbox-pg.json b/docs/proofs/warehouse-inbox-pg.json
new file mode 100644
index 0000000..e4311d3
--- /dev/null
+++ b/docs/proofs/warehouse-inbox-pg.json
@@ -0,0 +1,29 @@
+{
+ "checked_at": "2026-11-02T12:40:43+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":25,\"passed\":25,\"assertions\":188,\"duration_ms\":17052}",
+ "races": [
+ {
+ "scenario": "same_page",
+ "real_lock_contention": true,
+ "facts": 1,
+ "checkpoint_version": 2,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_pages",
+ "real_lock_contention": true,
+ "facts": 1,
+ "checkpoint_version": 2,
+ "result": "passed"
+ },
+ {
+ "scenario": "process_exit_after_commit_before_acknowledgement",
+ "result": "passed",
+ "facts": 1,
+ "checkpoint_version": 2
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-inbox.json b/docs/proofs/warehouse-inbox.json
new file mode 100644
index 0000000..5ea6741
--- /dev/null
+++ b/docs/proofs/warehouse-inbox.json
@@ -0,0 +1,37 @@
+{
+ "checked_at": "2026-11-02T12:43:36.6891210Z",
+ "module": "warehouse receiving journal and bounded pull",
+ "regression": {
+ "assertions": 628,
+ "log": "storage/logs/warehouse-inbox-regression.txt",
+ "tests": 56,
+ "result": "passed"
+ },
+ "disposable_postgresql": {
+ "real_lock_races": 2,
+ "proof": "warehouse-inbox-pg.json",
+ "assertions": 188,
+ "process_exit_after_commit_recovered": true,
+ "tests": 25,
+ "result": "passed"
+ },
+ "http": "warehouse-exchange-http.json",
+ "phpstan": "passed",
+ "pint": "passed",
+ "runtime_hashes": 18,
+ "source_sha256": {
+ "packages/integration/bootstrap.php": "deb81b6651ba6e26d105dd7fe97935390a859d0265423d160834fdb4e98d06f5",
+ "packages/integration/src/WarehouseFeed.php": "7252e93168460964907038ea979e1932d605cb935be46d4f119e4f974d8e13fc",
+ "packages/integration/src/WarehouseInbox.php": "5c45508ebb9050266aa8d8a5ee9119ce446fd1eecea71aa50bb354369637a034",
+ "packages/integration/src/WarehousePull.php": "8dcf20c6464e9cb6da712eb1d9fba89dd8e82b6218cd41c71be1b33913a4f3e3",
+ "app/Services/WarehouseExchange.php": "422f6aa4c654f1f0e859e1983297291c9085c4e60236c2e576a630523d744be0"
+ },
+ "remote_transport_implemented": false,
+ "host_business_projection_implemented": false,
+ "persistent_receiver_schema_installed": false,
+ "persistent_business_accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "full_suite_rerun": false,
+ "frontend_build": false,
+ "visual_review": false
+}
diff --git a/docs/proofs/warehouse-pilot-readiness-http.json b/docs/proofs/warehouse-pilot-readiness-http.json
new file mode 100644
index 0000000..cfe7878
--- /dev/null
+++ b/docs/proofs/warehouse-pilot-readiness-http.json
@@ -0,0 +1,43 @@
+{
+ "checked_at": "2026-11-02T12:00:27.791Z",
+ "environment": "http://127.0.0.1:8005",
+ "component": "stock/pilot",
+ "synthetic_evaluation": true,
+ "registered_count": 12,
+ "warehouse_scenario_ready": false,
+ "replenishment_scenario_ready": false,
+ "client_configuration_complete": false,
+ "checks": [
+ {
+ "key": "installation",
+ "ok": true
+ },
+ {
+ "key": "participants",
+ "ok": false
+ },
+ {
+ "key": "catalogue",
+ "ok": false
+ },
+ {
+ "key": "replenishment",
+ "ok": false
+ },
+ {
+ "key": "scheduler",
+ "ok": true
+ },
+ {
+ "key": "administration",
+ "ok": false
+ },
+ {
+ "key": "recovery",
+ "ok": true
+ }
+ ],
+ "accounts_or_grants_changed": false,
+ "business_commands": false,
+ "visual_browser_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-pilot-readiness-pg.json b/docs/proofs/warehouse-pilot-readiness-pg.json
new file mode 100644
index 0000000..33699ff
--- /dev/null
+++ b/docs/proofs/warehouse-pilot-readiness-pg.json
@@ -0,0 +1,25 @@
+{
+ "checked_at": "2026-11-02T11:58:49+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":46,\"passed\":46,\"assertions\":659,\"duration_ms\":47975}",
+ "races": [
+ {
+ "scenario": "same_operation",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_operations",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-pilot-readiness.json b/docs/proofs/warehouse-pilot-readiness.json
new file mode 100644
index 0000000..d80c5a9
--- /dev/null
+++ b/docs/proofs/warehouse-pilot-readiness.json
@@ -0,0 +1,128 @@
+{
+ "checked_at": "2026-11-02T12:02:47.6766865Z",
+ "scope": "SGC standalone controlled synthetic pilot",
+ "implemented": [
+ "administrator read-only pilot diagnostics",
+ "independence and cost-center checks",
+ "current catalogue and automatic-rule source inspection",
+ "shared resilient scheduler telemetry",
+ "pending technical work is not hidden by a recent heartbeat",
+ "integrated synthetic warehouse/replenishment-draft journey"
+ ],
+ "regression": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 53,
+ "passed": 53,
+ "assertions": 705,
+ "duration_ms": 17606
+ },
+ "final": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 8,
+ "passed": 8,
+ "assertions": 142,
+ "duration_ms": 5102
+ },
+ "postgresql": {
+ "checked_at": "2026-11-02T13:58:49+02:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":46,\"passed\":46,\"assertions\":659,\"duration_ms\":47975}",
+ "races": [
+ {
+ "scenario": "same_operation",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_operations",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+ },
+ "http": {
+ "checked_at": "2026-11-02T12:00:27.791Z",
+ "environment": "http://127.0.0.1:8005",
+ "component": "stock/pilot",
+ "synthetic_evaluation": true,
+ "registered_count": 12,
+ "warehouse_scenario_ready": false,
+ "replenishment_scenario_ready": false,
+ "client_configuration_complete": false,
+ "checks": [
+ {
+ "key": "installation",
+ "ok": true
+ },
+ {
+ "key": "participants",
+ "ok": false
+ },
+ {
+ "key": "catalogue",
+ "ok": false
+ },
+ {
+ "key": "replenishment",
+ "ok": false
+ },
+ {
+ "key": "scheduler",
+ "ok": true
+ },
+ {
+ "key": "administration",
+ "ok": false
+ },
+ {
+ "key": "recovery",
+ "ok": true
+ }
+ ],
+ "accounts_or_grants_changed": false,
+ "business_commands": false,
+ "visual_browser_review": false
+ },
+ "checks": {
+ "types": "passed",
+ "build": "passed",
+ "common_runtime_sha256_files": 18,
+ "formatting_and_lint": "passed",
+ "scoped_php_analysis": "passed"
+ },
+ "journey": {
+ "initial_usable_milli": 120000,
+ "final_physical_milli": 103000,
+ "automatic_drafts": 1,
+ "requested_milli": 125000,
+ "incoming_source": "pre-existing issued order, not automatic approval of the new draft",
+ "last_delivery_milli": 25000,
+ "remaining_reservation_milli": 0,
+ "first_delivery_milli": 100000,
+ "draft_quantity_milli": 108000,
+ "automatic_approval_or_budget": false,
+ "reservation_refill_milli": 5000,
+ "warehouse_state": "closed",
+ "internal_invoice_created": false,
+ "incoming_receipt_milli": 108000,
+ "purchase_state": "draft"
+ },
+ "synthetic_validation_only": true,
+ "persistent_accounts_or_grants_changed": false,
+ "new_migration": false,
+ "sgf_real_changed": false,
+ "real_persons_created": false,
+ "full_visual_review": false,
+ "historical_general_suite_rerun": false,
+ "client_readiness_not_certified": true
+}
diff --git a/docs/proofs/warehouse-replenishment.json b/docs/proofs/warehouse-replenishment.json
new file mode 100644
index 0000000..c9467ec
--- /dev/null
+++ b/docs/proofs/warehouse-replenishment.json
@@ -0,0 +1,79 @@
+{
+ "checked_at": "2026-11-02T06:29:42.3293983Z",
+ "environment": "SGC local evaluation 8005",
+ "functionality": [
+ "physical reservation at independent authorization",
+ "atomic reservation consumption and physical delivery",
+ "authorized cancellation of outstanding balance",
+ "protection in the common stock engine",
+ "article and warehouse automatic purchase configuration",
+ "independent activation without automatic financial approval",
+ "source destination and equipment preserved",
+ "pending need when configuration is missing or invalid",
+ "replenishment coverage and duplicate prevention"
+ ],
+ "full_suite": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 920,
+ "passed": 918,
+ "assertions": 9620,
+ "duration_ms": 188408,
+ "skipped": 2
+ },
+ "targeted_suite": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 24,
+ "passed": 24,
+ "assertions": 259,
+ "duration_ms": 11151
+ },
+ "postgresql": {
+ "checked_at": "2026-11-02T08:24:01+02:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":24,\"passed\":24,\"assertions\":232,\"duration_ms\":20682}",
+ "races": [
+ {
+ "scenario": "same_operation",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_operations",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+ },
+ "installation_proof": "warehouse-reservations-install.json",
+ "http_proof": "approval-routing-http.json",
+ "verification": {
+ "types": "passed",
+ "build": "passed",
+ "scoped_php_analysis": "passed",
+ "common_runtime_files_sha256": 18,
+ "formatting_and_lint": "passed",
+ "screenshots": false
+ },
+ "existing_data_preserved": true,
+ "accounts_or_grants_created": false,
+ "invoice_created_for_internal_use": false,
+ "automatic_submission_or_approval": false,
+ "limitations": [
+ "reservation refill after new stock requires explicit reviewer action",
+ "no durable outbox recovery or scheduled time-only validity scan yet",
+ "new equipment and services retain their dedicated workflows",
+ "sales invoicing remains SGF composition",
+ "manual purchases without authorized article and warehouse provenance are not inferred",
+ "warehouse-specific workflow notices are a separate increment"
+ ]
+}
diff --git a/docs/proofs/warehouse-reservations-install.json b/docs/proofs/warehouse-reservations-install.json
new file mode 100644
index 0000000..315d317
--- /dev/null
+++ b/docs/proofs/warehouse-reservations-install.json
@@ -0,0 +1,14 @@
+{
+ "checked_at": "2026-11-02T06:15:55+00:00",
+ "migration": "2026_11_02_000049_create_warehouse_reservations",
+ "environment": "local evaluation 8005",
+ "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261102-061551\/before.dump",
+ "backup_sha256": "061bf1eee629dcc24c71cefc138b1a4dd25a26567090d5e9a155bd8e1fa33ba5",
+ "restore_verified": true,
+ "existing_tables_preserved": 109,
+ "new_tables": {
+ "warehouse_reservations": 0
+ },
+ "accounts_or_grants_changed": false,
+ "domain_installed": false
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-workdesk-http.json b/docs/proofs/warehouse-workdesk-http.json
new file mode 100644
index 0000000..f68e7eb
--- /dev/null
+++ b/docs/proofs/warehouse-workdesk-http.json
@@ -0,0 +1,12 @@
+{
+ "checked_at": "2026-11-02T11:23:59.699Z",
+ "environment": "http://127.0.0.1:8005",
+ "component": "stock/requests",
+ "read_only_administrator": true,
+ "task_views_enforce_permissions": true,
+ "filters_and_pagination": true,
+ "profile_tasks_present": true,
+ "accounts_or_grants_changed": false,
+ "business_commands": false,
+ "visual_browser_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-workdesk-pg.json b/docs/proofs/warehouse-workdesk-pg.json
new file mode 100644
index 0000000..e62d5fb
--- /dev/null
+++ b/docs/proofs/warehouse-workdesk-pg.json
@@ -0,0 +1,25 @@
+{
+ "checked_at": "2026-11-02T11:19:30+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":38,\"passed\":38,\"assertions\":517,\"duration_ms\":42347}",
+ "races": [
+ {
+ "scenario": "same_operation",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_operations",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/warehouse-workdesk.json b/docs/proofs/warehouse-workdesk.json
new file mode 100644
index 0000000..85ebd05
--- /dev/null
+++ b/docs/proofs/warehouse-workdesk.json
@@ -0,0 +1,85 @@
+{
+ "checked_at": "2026-11-02T11:26:23.4111887Z",
+ "scope": "SGC standalone / synthetic development",
+ "implemented": [
+ "permission-scoped live warehouse task views",
+ "complete state counts and filters before pagination",
+ "outstanding reservations and availability/source attention",
+ "current eligible people without new grants",
+ "unconfirmed receipt remains visible after balance cancellation",
+ "read-only coherent PostgreSQL list and detail",
+ "collapsed draft form without additional mandatory fields"
+ ],
+ "regression": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 111,
+ "passed": 111,
+ "assertions": 1008,
+ "duration_ms": 28991
+ },
+ "final_requests": {
+ "tool": "pest",
+ "result": "passed",
+ "tests": 18,
+ "passed": 18,
+ "assertions": 326,
+ "duration_ms": 6521
+ },
+ "postgresql": {
+ "checked_at": "2026-11-02T13:19:30+02:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":38,\"passed\":38,\"assertions\":517,\"duration_ms\":42347}",
+ "races": [
+ {
+ "scenario": "same_operation",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "different_operations",
+ "real_lock_contention": true,
+ "result": "passed"
+ },
+ {
+ "scenario": "automatic_purchase_activation_retry",
+ "real_lock_contention": true,
+ "single_draft": true,
+ "result": "passed"
+ }
+ ],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+ },
+ "http": {
+ "checked_at": "2026-11-02T11:23:59.699Z",
+ "environment": "http://127.0.0.1:8005",
+ "component": "stock/requests",
+ "read_only_administrator": true,
+ "task_views_enforce_permissions": true,
+ "filters_and_pagination": true,
+ "profile_tasks_present": true,
+ "accounts_or_grants_changed": false,
+ "business_commands": false,
+ "visual_browser_review": false
+ },
+ "verification": {
+ "types": "passed",
+ "common_runtime_sha256_files": 18,
+ "formatting_and_lint": "passed",
+ "scoped_php_analysis": "passed",
+ "build": "passed"
+ },
+ "synthetic_tests_only": true,
+ "persistent_accounts_or_grants_changed": false,
+ "new_migration": false,
+ "sgf_real_changed": false,
+ "real_persons_created": false,
+ "full_visual_review": false,
+ "historical_full_suite_rerun": false,
+ "limitations": [
+ "live workdesk alerts; no external delivery or persisted unread notifications",
+ "no inferred reservation expiry or allocation priority",
+ "persistent evaluation accounts retain their existing explicit permissions"
+ ]
+}
diff --git a/docs/proofs/workflow-task-inbox-http.json b/docs/proofs/workflow-task-inbox-http.json
new file mode 100644
index 0000000..0734a67
--- /dev/null
+++ b/docs/proofs/workflow-task-inbox-http.json
@@ -0,0 +1,23 @@
+{
+ "checked_at": "2026-11-02T15:58:04.242Z",
+ "environment": "http://127.0.0.1:8005",
+ "routes": [
+ {
+ "area": "procurement",
+ "result": "passed",
+ "total": 0,
+ "shown": 0
+ },
+ {
+ "area": "people",
+ "result": "passed",
+ "total": 0,
+ "shown": 0
+ }
+ ],
+ "existing_account_used": true,
+ "business_commands": false,
+ "accounts_or_grants_changed": false,
+ "sgf_installation_changed": false,
+ "visual_review": false
+}
\ No newline at end of file
diff --git a/docs/proofs/workflow-task-inbox-pg.json b/docs/proofs/workflow-task-inbox-pg.json
new file mode 100644
index 0000000..e839f2a
--- /dev/null
+++ b/docs/proofs/workflow-task-inbox-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-11-02T15:55:05+00:00",
+ "database": "disposable PostgreSQL",
+ "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":15,\"passed\":15,\"assertions\":362,\"duration_ms\":76212}",
+ "races": [],
+ "persistent_data_changed": false,
+ "database_removed_in_finally": true
+}
\ No newline at end of file
diff --git a/docs/proofs/workflow-task-inbox.json b/docs/proofs/workflow-task-inbox.json
new file mode 100644
index 0000000..a6304ca
--- /dev/null
+++ b/docs/proofs/workflow-task-inbox.json
@@ -0,0 +1,40 @@
+{
+ "checked_at": "2026-11-02T16:05:12.1783616Z",
+ "scope": "internal SGC tasks by area and current authority",
+ "regression": {
+ "assertions": 925,
+ "tests": 60,
+ "log": "storage/logs/workflow-task-inbox-regression.txt",
+ "result": "passed"
+ },
+ "disposable_postgresql": {
+ "assertions": 362,
+ "tests": 15,
+ "proof": "workflow-task-inbox-pg.json",
+ "result": "passed"
+ },
+ "new_tests": {
+ "tests": 8,
+ "assertions": 257
+ },
+ "http": "workflow-task-inbox-http.json",
+ "source_sha256": {
+ "app/Services/WorkflowTaskInbox.php": "05c2677fbc34d4bafaeff21fe05705c3752b4ab7895193f5990586cd516ad82c",
+ "app/Http/Controllers/WorkflowTaskInboxController.php": "554bfda1eef003db64ae8e6f1ae57af2e31615cd4f8c29780733008e90cb0967",
+ "resources/js/pages/workflow/tasks.tsx": "0ea91998ecb78809efc731135c14d302ddc7982be1119ef71a02bf80deb69674",
+ "resources/js/pages/stock/request.tsx": "4c9d2e5854159e0e4a0022065cae76bdc5ad1ab683e4397ea920f5065372862c",
+ "resources/js/lib/procurement-navigation.ts": "0aa7a29ae31dcfb9204325cae441e70c89c6422c52196e041fd1cda8422d0cbd"
+ },
+ "types": "passed",
+ "phpstan": "passed",
+ "frontend_lint": "passed",
+ "frontend_build": "passed",
+ "runtime_hashes": 18,
+ "new_persistent_schema_accounts_grants_or_business_records": false,
+ "new_workflow_engine": false,
+ "external_notifications_sent": false,
+ "sgf_installation_changed": false,
+ "full_suite_rerun": false,
+ "new_concurrency_races": false,
+ "visual_review": false
+}
diff --git a/docs/proposta-desenvolvimento-sgf.md b/docs/proposta-desenvolvimento-sgf.md
new file mode 100644
index 0000000..449913a
--- /dev/null
+++ b/docs/proposta-desenvolvimento-sgf.md
@@ -0,0 +1,65 @@
+# Proposta única — percurso completo no desenvolvimento isolado
+
+02/11/2026. **Proposta para confirmação; nenhuma alteração de acesso executada.** Substitui como próximo passo a instalação técnica desactivada no SGF real. O objectivo é disponibilizar e deixar utilizável o percurso requisição → submissão → aprovação independente → consulta na instância sintética existente, sem criar outra instalação.
+
+## Destino existente, confirmado por leitura
+
+| Elemento | Valor |
+| --- | --- |
+| Pasta reutilizada | `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\development-app` |
+| URL | `http://127.0.0.1:8039` |
+| Cluster exclusivo | `sgf_modular_regression_2576427d1939`, loopback `127.0.0.1:55439` |
+| Base persistente | `sgf_modules_dev_2576427d1939` |
+| Ligação existente/proprietário da base | `sgf_modules_qa_2576427d1939` |
+| Tenant sintético existente | `Cliente de demonstração` |
+| Empresa sintética existente | `Empresa Horizonte — Demonstração` |
+| Unidade sintética escolhida | `Unidade Central — Demonstração` |
+| Contas existentes a reutilizar | Desenvolvimento — Preparador; Desenvolvimento — Revisor; Desenvolvimento — Consulta |
+
+A leitura PostgreSQL em transacção READ ONLY confirmou base, role/proprietário, nomes sintéticos e ausência de tabelas `cb_*`, `ir_*`, `sgc_installations`. A documentação da navegação modular confirma a origem sintética das contas. Resolver os IDs exactos pelo registo privado de provisionamento e conferir tenant/empresa/unidade; não usar igualdade de nome para fundir entidades. Outras empresas/unidades e respectivos dados permanecem preservados e fora do âmbito da integração proposta. Não recriar a base de regressão nem reaplicar o provisionador destrutivo.
+
+## Configuração sintética proposta — ainda não aplicada
+
+Estes valores são deliberadamente **dados de demonstração**, sujeitos à confirmação desta proposta; não representam centros, dotações, pessoas, impostos ou políticas da Tempo.
+
+| Parâmetro | Proposta concreta |
+| --- | --- |
+| Área/opção | Compras / Compras internas (`procurement.internal`) |
+| Módulo | `purchasing`, activado exclusivamente no âmbito sintético acima |
+| Centro | Código `SINTETICO-REPRO`, departamento/referência `OPER` |
+| Dotação | Exercício **2026**, natureza operacional (`operating`), **10.000,00 MZN sintéticos**, inclui impostos; preparação e aprovação por identidades distintas |
+| Política | Activa; orçamento obrigatório; cálculo de limites com impostos; primeiro limite 1.000.000 unidades mínimas (10.000,00), segundo 2.000.000 (20.000,00); centro e preparador/revisor vinculados a `OPER` |
+| Recorte executável | Consumíveis, serviços e outros; equipamentos/peças rejeitados; sem contratação para terceiros |
+| Exemplo manual | Requisição sintética de 100,00, sem imposto, inferior ao primeiro limite; não declara taxa fiscal aplicável à empresa |
+
+Os valores retomam a configuração já exercitada na prova isolada. A dotação limita os pedidos deste exemplo ao primeiro nível; não se promete exercitar os níveis de compras/finanças sem identidades/direitos adicionais. A submissão reserva orçamento pelo motor existente, e as repetições não criam outra requisição/reserva. Nenhum valor é importado do SGF real. Criar centro/dotação pela interface/API existente, com autoria, motivo e chaves de idempotência.
+
+## Identidades e acessos funcionais a confirmar
+
+Reutilizar as três contas sintéticas existentes, suas credenciais privadas e permissões anteriores; acrescentar apenas os direitos abaixo no âmbito exacto escolhido, com auditoria. Não criar contas, publicar palavras-passe ou atribuir estes perfis a pessoas reais.
+
+| Identidade existente | Responsabilidade proposta | Direitos adicionais da candidata |
+| --- | --- | --- |
+| Desenvolvimento — Preparador | Administrar/configurar este exemplo, criar centro, preparar dotação/requisição e submeter | `purchasing_requisitions.view`, `.prepare`, `.configure`, `.approve.department_head`; `purchasing_budget.view`, `.create`, `.approve`, `.record` |
+| Desenvolvimento — Revisor | Aprovar a dotação e a requisição preparadas pela outra identidade; consultar | `purchasing_requisitions.view`, `.approve.department_head`; `purchasing_budget.view`, `.approve` |
+| Desenvolvimento — Consulta | Consultar requisição e orçamento; sem escrita/aprovação/administração | `purchasing_requisitions.view`; `purchasing_budget.view` |
+
+Os sufixos da tabela pertencem ao prefixo da mesma célula. O Preparador conserva a administração sintética existente (`access.manage` na empresa). Os direitos de aprovação na conta administradora permitem a delegação nativa de direitos que detém; **não permitem aprovar a sua própria requisição ou dotação**. O Revisor não recebe preparação ou administração. A Consulta não recebe mutações. Manter os direitos nativos já existentes de consulta da empresa/unidade e pertenças; não ampliar o seu âmbito. A configuração inicial usa o mecanismo explícito e auditado da candidata; posteriores outorgas/revogações usam o catálogo nativo.
+
+**Não é exigido um segundo administrador para esta demonstração isolada.** A orientação existente do proprietário, secção «Administração e entrega» do AGENTS.md, diz: «A configuração inicial/entrega exige dois administradores nominativos da empresa» e «Manter pelo menos dois administradores permanentes». Trata-se de requisito para a configuração/entrega empresarial; esta proposta não é essa entrega, não altera a administração real nem cria uma nova exigência para a demonstração. Preparador e Revisor continuam obrigatoriamente identidades diferentes para aprovar.
+
+## SQL: direitos existentes e alterações propostas
+
+**Nenhum novo GRANT, REVOKE, CREATE ROLE, ALTER ROLE ou transferência de ownership é proposto para este ambiente.** A role existente `sgf_modules_qa_2576427d1939` já é proprietária da base isolada e foi criada para esse desenvolvimento. Reutilizá-la para migração e execução evita alterar acessos ou criar outra instalação/role por arrasto. Isso mantém uma limitação explícita: este ambiente persistente não demonstra separação owner/runtime com privilégio mínimo; essa separação já foi ensaiada nas bases descartáveis da prova corrigida, não é atribuída retroactivamente a esta instância.
+
+Não executar o plano `sgf-corrected-sql-plan.json`: ele é dirigido a `sgf_dev_app` da implantação real e **não pertence a esta proposta**. Não criar ou conceder acesso dessa role ao cluster 55439. As escritas funcionais em `permission_grants` da base sintética, discriminadas acima, também aguardam confirmação. A migração autorizada futura cria 16 tabelas e altera o catálogo de `permission_grants` somente nesta base sintética; DDL não equivale a conceder direitos. Caso a conferência mostre que a role existente não tem a autoridade necessária, parar e apresentar os comandos SQL exactos em falta para nova confirmação, sem os executar e sem promover a role.
+
+## Candidata, execução e recuperação propostas
+
+Reutilizar o ZIP existente `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`, SHA-256 `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703`, manifesto `a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b`. Os planos/fingerprints do destino real **não se aplicam** a esta instância. Gerar os seus planos próprios; a normalização física de guardas para 55439 já documentada deve ser respeitada. Se os overlays exactos do ZIP recusarem diferenças dessa cópia, preparar variante de compatibilidade identificada e apresentar novo hash antes de aplicação; não desligar guardas nem assumir compatibilidade byte a byte.
+
+Após confirmação da proposta completa: guardar backup privado desta base/configuração/código sintéticos; ensaiar recuperação sem sobrescrever a base; preparar schema e código com planos próprios, habilitar a composição só no âmbito acima, atribuir os direitos explicitados, preparar/aprovar a dotação, e deixar a instância acessível em 8039. Conferir o percurso por HTTP e reutilizar as provas aceites de concorrência/recuperação; não repetir navegador bloqueado ou suites integrais por rotina. Guardar configurações/credenciais privadas fora de Git/raiz web e fornecer o local existente de acesso, sem imprimir segredos.
+
+Em falha, manter esta instância fechada, preservar journal e restaurar código ou recuperar dados/schema em base alternativa, conforme a natureza da falha; não recriar os perfis nem resetar a demonstração. Código e dados têm recuperação separada. O cluster/base 55432 e a configuração real da Tempo não participam em nenhuma dessas operações.
+
+**Confirmação pendente:** reutilização desta instância, habilitação da candidata e configuração/acessos sintéticos acima. Não foi executada nenhuma alteração nesta proposta. Implantação real permanece etapa posterior separada; `sgf_tempo_dev` e o seu pacote **0.1.1** são preservados.
diff --git a/docs/stock-read-common-delivery.md b/docs/stock-read-common-delivery.md
new file mode 100644
index 0000000..4ffb9ed
--- /dev/null
+++ b/docs/stock-read-common-delivery.md
@@ -0,0 +1,11 @@
+# Consulta comum de Stock — composição Compras
+
+02/11/2026. Adaptador local `CommonStockReadController`, rota autenticada `GET /api/stock-read` e artefacto local verificado `packages/inventory-read/releases/0.1.0`. O leitor e o protocolo de consulta têm fonte única em `sgf-tempo/packages/inventory-read`; o artefacto não exige a pasta SGF na instalação em runtime.
+
+Reutiliza as funções nativas da consulta de Stock e os perfis da organização; exige mapeamento explícito de armazéns por tenant/actor. Sem novos direitos. A fonte fica vazia por omissão e esta capacidade permanece exclusivamente QA, com verificação de ambiente, base/role próprias e cluster. Não substitui a página operacional nem os escritores `StockWorkflow`/`ReceiptWorkflow` existentes.
+
+Prova nativa numa cópia da aplicação e base nova isolada: **1 teste / 73 asserções**. Comparação com a API SGF: **12 cenários de linhas/paginação equivalentes**, com teste SGF **1 / 48 asserções**, mais **2 / 77** casos funcionais SGF afectados. Inclui funções/perfis, revogação, isolamento, mudança de versão, indisponibilidade/recuperação e recusa de token entre composições. Kernels HTTP com actores sintéticos pré-autenticados; não são provas de login/rede/navegador ou implantação.
+
+Manifesto do leitor SHA-256 `bbd97f63b0eeb671bd3ad57bbd62bd34fcf67b8e943320423715d514a2a2c19c`. Esta versão do leitor não activa uma candidata Purchasing. Sem migrações/concessões no destino, publicação ou checkpoint; dados/demos anteriores preservados. A base QA e os seus exemplos foram conservados; credenciais e tokens ficam privados.
+
+Relatório completo e prova sanitizada no repositório SGF: `docs/STOCK_READ_COMPOSITION_DELIVERY.md` e `docs/proofs/stock-read-composition-20261102.json`. Pendentes: ligação da UI nativa quando delimitada, demonstração persistente, recepção/consumo com escritor comum, revisão visual e capacidade de implantação. O stock operacional existente em Compras mantém os seus âmbitos e não foi revalidado por esta consulta nova.
diff --git a/docs/transferencia-sgf.md b/docs/transferencia-sgf.md
index 0d22b41..51cd131 100644
--- a/docs/transferencia-sgf.md
+++ b/docs/transferencia-sgf.md
@@ -1,5 +1,173 @@
# Transferência de Compras e actualizações do SGF
+## Consultas/cotações/adjudicação — estado actual, 02/11/2026
+
+[Entrega para revisão, mapeamento, hashes, testes e recuperação](consultas-adjudicacao-sgf.md). Candidata **`OPERATIONAL-CANDIDATE-040fb0cb67a77d61`**, ZIP SHA-256 `dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a`, manifesto `040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58`.
+
+| Percurso/capacidade | Estado na demonstração 8039 | Prova/pendência |
+| --- | --- | --- |
+| Requisição → reserva → aprovação independente → leitura Consulta | **Transferido e preservado** | Origem ID 1/100 MZN/versão 3, três eventos e reserva única intactos. |
+| Seleccionar fornecedores → registar/rever cotações → comparar | **Transferido no contexto sintético 1/1/1** | Três fornecedores/cotações; leitura e recusas no servidor verificadas. |
+| Avaliar → propor → confirmar adjudicação independente | **Parcialmente transferido** | Fluxo completo passou na cópia isolada, incluindo concorrência e obsolescência. Em 8039 há um avaliador por cotação e nenhum confirmador; permanece bloqueado conforme decisão de não criar quarta conta, sem reduzir as regras de Compras. |
+| Direitos | **Três acréscimos autorizados aplicados** | ID 1: `purchasing_sourcing.prepare`; ID 2: `purchasing_sourcing.evaluate` e `purchasing_sourcing.propose`, no âmbito 1/1/1. Consulta sem acréscimos; nenhum confirm concedido. |
+| Schema/actualização/recuperação | **Preparados e ensaiados** | Quatro tabelas SS, CHECK de catálogo e ledger próprio; código revertido e dados/schema restaurados em cópias, sem confundir ambas as recuperações. |
+| Valores cotados diferentes da reserva / cadeia acima do primeiro limite | **Pendente** | Sinalizados/bloqueados; nenhuma regra, reforço ou efeito financeiro inventado. |
+| Encomenda, recepção, pagamentos e implantação real | **Fora deste incremento / pendentes** | Nenhum efeito criado; SGF real permanece **0.1.1**. |
+
+**Não é integração integral nem adjudicação concluída em 8039.** O proprietário autorizou apenas os direitos propostos para as identidades existentes e escolheu preparar sem criar outra conta. Foram preservados requerente fora da avaliação/adjudicação, dois avaliadores mínimos e proponente diferente do confirmador. [Prova agregada](proofs/sgf-sourcing-delivery.json), [cópias isoladas](proofs/sgf-sourcing-isolated.json), [HTTP 8039](proofs/sgf-sourcing-demo-http.json), [preservação 8039](proofs/sgf-sourcing-demo.json). Revisão visual separada. Sem novas contas persistentes, 8041, GRANT/REVOKE SQL, publicação ou contactos externos. Os estados abaixo conservam o histórico anterior.
+
+## Primeiro percurso integrado fechado em 8039 — leitura corrigida
+
+**02/11/2026: concluído no âmbito funcional sintético autorizado**, sem declarar integração integral de Compras. Preparação → submissão/reserva → aprovação independente → listagem/detalhe/histórico, incluindo Consulta, estão verificados na demonstração existente **8039 / 55439 / contexto 1/1/1**. Reutilizada a requisição ID 1 de **100 MZN**, versão 3; uma reserva preservada. A limitação de leitura registada abaixo fica resolvida neste incremento. Revisão visual permanece separada.
+
+A correcção só altera `Port::authorize()` do adaptador SGF: `view` autoriza leitura dos registos carregados no contexto permitido, mantendo pertença, permissão efectiva, empresa/unidade, opção, token e validações próprias de escrita. Sem novos direitos, contas, DCL SQL, schema, configuração ou dados de negócio. Recusas ao Consulta para preparar/gravar/submeter/decidir/configurar/aprovar e tentativas de contexto/opção/empresa/unidade indevidos verificadas por HTTP.
+
+### Candidata sucessora e provas afectadas
+
+| Elemento | Identificação |
+| --- | --- |
+| Candidata | `OPERATIONAL-CANDIDATE-166e397e3ca63f02` |
+| ZIP SHA-256 | `620660d76329c800f3c6b18a521c03ccdadce09502d2a7744e1e91b77560f785` |
+| Manifesto SHA-256 | `166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de` |
+| Mudança face à anterior 401ce31ae5f2dd20 | Somente `integrations/sgf/internal-requisitions/Port.php`; restante conteúdo preservado byte a byte |
+| Aplicação final na demonstração | `3afbba5e3ed60b853d62b869` — após reversão e reaplicação verificadas |
+| Provas | [Entrega](proofs/sgf-read-8039-delivery.json), [HTTP corrigida](proofs/sgf-read-8039-http.json), [versão anterior recuperada](proofs/sgf-read-8039-rollback.json), [reconciliação de dados](proofs/sgf-read-8039-data.json), [artefacto](proofs/sgf-read-candidate.json) |
+
+O ensaio aplicou a sucessora, reverteu para a anterior (404 e omissão do Consulta reproduzidos) e reaplicou a sucessora (leitura restabelecida). Nenhuma recriação/submissão/reserva foi necessária. Foram reconciliadas as **109 tabelas efectivamente presentes nesta demonstração**: preservados dados de negócio, contas, direitos e auditoria anterior; novos eventos de auditoria foram apenas acrescentados pelos testes de recusa. Não confundir esta contagem com as 103 da reprodução descartável anterior. Configuração e ponteiro 0.1.1 preservados. Não foi necessário novo ensaio de migração/restauro de schema: não houve mudança de schema. Provas anteriores válidas de concorrência, migração, restauro, revogação e caches reutilizadas, sem repetir suites integrais.
+
+Antes de aplicar foi criado backup custom privado da base sintética, SHA-256 `e07756b650d44361c0b2fce0eb170d0b54e7753b61c624a21b2524104e397055`, com índice conferido. Recuperação/journals em `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\private\read-correction-recovery`; backups nativos de código na pasta privada da demonstração. A reversão de código foi efectivamente exercitada; não se declara novo restauro integral desse dump. Credenciais e dumps não fazem parte da candidata.
+
+### Matriz actual e próximo percurso escolhido
+
+| Processo | Demonstração 8039 | Implantação real |
+| --- | --- | --- |
+| Requisição interna → reserva → aprovação independente → consulta | **Transferido e verificado no âmbito sintético**; leitura Consulta corrigida, recusas mantidas | **Pendente**, nenhuma candidata activada |
+| Orçamento/centro/dotação necessários ao percurso | **Transferido e configurado sinteticamente** | Pendente configuração empresarial própria |
+| Actualização/reversão de código desta correcção | **Verificadas**, sem alterar schema/direitos/dados de negócio | Não executadas |
+| Consulta de fornecedores, cotações/comparação e adjudicação | **Próximo percurso completo escolhido**, ainda pendente de incorporação | Pendente |
+| Encomenda/contrato, recepção, conferência de factura e liquidação | Pendentes ou parciais conforme matriz histórica; não cobertos por requisições | Sem nova implantação |
+
+Próximo incremento escolhido: **requisição aprovada → abertura do processo de compra → consulta/recolha de cotações → comparação → decisão independente de adjudicação → consulta do histórico**. Reutilizar os processos já existentes de Compras e as suas origens/versionamento, sem criar outra requisição nem reservar duas vezes o mesmo montante. Primeiro mapear adaptadores/ecrãs e lacunas desse percurso; não activar 0.1.2 por arrasto nem declarar entregues encomenda, recepção ou pagamento. Este registo escolhe a sequência de trabalho, não inicia alterações de acesso/instalação real.
+
+O SGF real continua em **0.1.1**, com `sgf_tempo_dev` e configuração Tempo preservados. Não foi criada 8041, não houve publicação, browser ou alteração de direitos públicos. As propostas/hashes anteriores abaixo permanecem como histórico; a candidata actual de requisições é a sucessora identificada nesta secção.
+
+## Demonstração 8039 habilitada — confirmação executada em 02/11/2026
+
+O proprietário confirmou os 14 direitos, o centro/dotação sintéticos e a habilitação em **8039 / base sgf_modules_dev_2576427d1939 / porta 55439 / contexto 1/1/1**. [Resultado e recuperação](demonstracao-requisicoes-8039.md): exactamente 14 acréscimos, contas/direitos anteriores preservados; centro SINTETICO-REPRO/OPER, dotação 10.000 MZN/2026 aprovada por outra identidade; requisição 1 de 100,00 aprovada pelo Revisor, uma reserva após retry e recusas de escrita ao Consulta. **Limitação:** a Consulta com apenas view não lê a requisição de outra identidade pela regra actual (404); nenhuma ampliação de direitos/regra feita por arrasto. Revisão visual separada. Sem 8041, novas contas ou GRANT/REVOKE SQL. Configuração da Tempo e implantação real permanecem preservadas em 0.1.1; esta execução não activa candidata no destino real. A proposta pendente abaixo fica sucedida exclusivamente para o âmbito sintético autorizado.
+
+## Próximo âmbito substituído pelo proprietário — desenvolvimento isolado
+
+Não avançar com a instalação técnica desactivada no destino real. A [proposta única de desenvolvimento](proposta-desenvolvimento-sgf.md) reutiliza a instância persistente existente em 8039, pasta `modular-regression-2576427d1939/development-app`, base `sgf_modules_dev_2576427d1939` no cluster exclusivo 55439, e suas identidades sintéticas. Especifica empresa/unidade, centro/dotação/política de demonstração e direitos por identidade, todos pendentes de confirmação. Não propõe novos GRANT/REVOKE: explicita a reutilização e a limitação da role proprietária existente nessa instância. Não exige segundo administrador na demonstração; identifica a regra empresarial de entrega separadamente. Nenhuma alteração de acesso foi executada. SGF real/Tempo preservados em **0.1.1**; proposta de implantação real abaixo fica adiada, não autorizada.
+
+## Candidata corrigida em reprodução isolada — 02/11/2026
+
+**Estado actual:** candidata `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20` preparada e verificada; **SGF permanece em 0.1.1, sem instalação, activação ou alterações SQL no destino**. Esta secção sucede aos bloqueantes da proposta anterior, preservada abaixo. [Decisão actualizada e procedimento de instalação/recuperação](decisao-instalacao-sgf.md); [prova com 157 controlos](proofs/sgf-corrected-delivery.json); [proposta SQL não aplicada](proofs/sgf-corrected-sql-plan.json).
+
+[ZIP privado](../storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20.zip): SHA-256 `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703`; manifesto `a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b`. Distribuição autónoma com assets compilados; não depende da pasta Compras no cliente, nem inclui fixtures, credenciais ou dados de desenvolvimento.
+
+### Matriz reconciliada — prevalece sobre estados históricos abaixo
+
+| Processo/adaptador/ecrã | Estado no destino | Resultado da candidata corrigida |
+| --- | --- | --- |
+| Projecção partilhada de stock 0.1.1 | **Transferido** | Ponteiro e comportamento instalado preservados. |
+| Requisição interna → submissão → aprovação independente → consulta | **Parcialmente transferido — candidata não instalada** | Motor partilhado e adaptador SGF ensaiados com três tipos básicos, reserva existente, origem/versões/histórico e permissões no servidor; provas anteriores de concorrência reutilizadas. |
+| Migração e dependências do orçamento | **Parcialmente transferido — candidata não instalada** | Revisão 2: 16 tabelas, incluindo `cb_ui_commands`/`cb_consumption_sources`; falha e restauro de schema/dados ensaiados em reprodução das 87 tabelas actuais. |
+| Centros/dotações pela interface | **Parcialmente transferido — candidata não instalada** | Novo ecrã de preparação; APIs autorizadas reutilizam o orçamento existente e aprovação independente. Valores reais continuam pendentes; nenhum seed sintético no pacote. |
+| Catálogo administrativo e ecrãs SGF | **Parcialmente transferido — candidata não instalada** | Catálogo/rótulos do servidor consumidos pelo frontend compilado; integração na entrada modular e gestão de concessões testadas sem outorga automática. |
+| Ligação migradora e SQL mínimo | **Pendente de confirmação da operação real** | Roles separadas ensaiadas; plano GRANT/REVOKE apenas para novos objectos, nunca executado no destino. Não promover runtime nem mudar direitos públicos por arrasto. |
+| Instalação/actualização/recuperação | **Parcialmente transferido — candidata não instalada** | Aplicação em cópia isolada, sucessora de código e reversão preservam dados; restauro reconcilia 103 tabelas e runtime restrito. Backup/restauro dos dados reais continua obrigatório na janela aprovada. |
+| Navegação modular SGF | **Transferido — aceitação técnica própria documentada** | Provas anteriores reutilizadas; entrada/assets e guardas conferidos na composição corrigida. Revisão visual, associações antigas e volume separados. |
+| Equipamentos/peças | **Fora do âmbito básico** | Tipos rejeitados no servidor; não impedem consumíveis/serviços/outros, nem são convertidos silenciosamente. |
+| Entrega/configuração real e activação | **Pendente** | Dois administradores nominativos, centros/dotações reais, perfis, política e âmbito definidos pela empresa; sem criação ou promoção automática. Contratações para terceiros continuam excluídas. |
+
+A reprodução exportou **apenas estrutura/metadados**, não dados de negócio/contas do destino. Criou centros/dotações sintéticos através das APIs, testou com runtime sem ownership/CREATE e restaurou em segunda base descartável. Bases e roles sintéticas removidas; destino reconferido sem alteração de schema, segurança ou ponteiro. Não se repetiram as suites integrais já aceites nem a via de navegador bloqueada. As referências à candidata antiga e às lacunas abaixo conservam valor histórico; não descrevem a cobertura actual.
+
+## Decisão de instalação — proposta sem execução, 02/11/2026
+
+O âmbito escolhido pelo proprietário é **decidir a instalação, sem a executar**. A [decisão concreta](decisao-instalacao-sgf.md) identifica projecto/base efectivos, candidata/hashes, schema/catálogos, bloqueantes e backup/restauro/recuperação. Os planos de leitura de código/schema passaram e o ZIP foi reconferido. O [inventário do destino](proofs/sgf-installation-decision-inventory.json) foi obtido em transacção PostgreSQL READ ONLY; nenhuma suite foi repetida.
+
+**Recomendação: não instalar esta candidata como percurso operacional ainda.** O destino `C:\laragon\www\sgf-tempo`, base `sgf_tempo_dev` em `127.0.0.1:55432`, tem 87 tabelas e usa `sgf_dev_app`, sem CREATE/ALTER necessários. Faltam a execução migradora/mapa de privilégios mínimos runtime e a preparação operacional de centros/dotações: não existem tabelas `cb_*`, a interface candidata apenas selecciona centros existentes e a interface orçamental actual depende de duas tabelas ausentes do schema candidato. As provas isoladas com role proprietária/cadastros preparados não cobriam esses aspectos do destino. Preservar o ZIP aprovado como evidência e resolver essas lacunas antes de propor a operação executável; novos bytes exigem novos hashes/provas.
+
+**Catálogo administrativo:** implementado/testado na candidata, ainda não instalado; não é falta de implementação, mas não constitui direitos já atribuídos. **Equipamentos/peças:** fora do recorte básico de consumíveis/serviços/outros, não são o seu bloqueio. Há uma concessão de administração da empresa no destino; a regra de dois administradores permanece pendente para entrega/activação, sem criar conta ou atribuir direitos por conveniência. A eventual instalação técnica desactivada é diferente de abrir o percurso aos utilizadores.
+
+**Navegação modular: tecnicamente aceite em 02/11/2026**, conforme [registo próprio do SGF](../../sgf-tempo/docs/MODULAR_NAVIGATION_DELIVERY.md). Manter separadas revisão visual/recargas, associações antigas e volume/paginação. Essa aceitação não autoriza instalar a candidata, alterar direitos públicos ou publicar. O SGF continua em **0.1.1**. Não foram executados `migrate`, `apply`, `cache`, backups, concessões, manutenção ou paragem/reinício de serviços.
+
+## Reconciliação com a entrega modular do SGF — 02/11/2026
+
+A conferência documental posterior encontrou uma entrega própria de navegação modular no SGF: [implementação e estado](../../sgf-tempo/docs/MODULAR_NAVIGATION_DELIVERY.md), [conclusão técnica](../../sgf-tempo/docs/MODULAR_NAVIGATION_COMPLETION.md) e [conferência de 02/11/2026](../../sgf-tempo/docs/proofs/modular-navigation-recheck-20261102.json). Portanto, **a indicação anterior «sem resultado apresentado» fica sucedida por «entrega técnica documentada, com encerramento pendente»**. Não repetir implementação ou testes já registados com base naquele estado histórico.
+
+Segundo essa entrega, os módulos são Comercial, Compras, Stock, Oficinas, Financeiro e Contabilidade; Início e Opções gerais estão separados do módulo activo. A entrega regista 185 testes/3.220 asserções de regressão, 8 testes/90 asserções focados e três perfis sintéticos num ambiente próprio. A conferência registada comparou 184 ficheiros com a cópia validada, confirmou hashes de JUnit e preservação de concessões/pertenças/auditoria/guarda/ponteiro, sem repetir testes ou provisionamento. Esta retoma de Compras **leu e reconciliou essas provas**, não executou novamente essa conferência nem as suites. Instância isolada documentada: `http://127.0.0.1:8039`.
+
+Continuam separados: [revisão visual pendente](../../sgf-tempo/docs/MODULAR_NAVIGATION_VISUAL_REVIEW.md), incluindo recargas de páginas autónomas; reconciliação humana das associações antigas; avaliação de volume/paginação antes de implantação. A interface usa «Opções gerais», não um submenu único literalmente chamado «Configuração». Não declarar aceitação visual, cobertura operacional dos módulos em preparação ou disponibilização comercial integral.
+
+Esta entrega modular não instala a candidata de requisições nem substitui as suas provas de código/schema/caches. A integração candidata continua preparada e verificada no âmbito descrito abaixo; o SGF de destino permanece em 0.1.1. **Não houve instalação, activação, alteração de código/permissões ou nova tentativa de navegador nesta reconciliação.** A decisão de instalar continua separada, conforme instrução do proprietário.
+
+## Actualização entre bundles e caches nativos — 02/11/2026
+
+**Concluído neste incremento candidato:** transição de código entre bundles já instalados, reversão para o anterior e compilação/recuperação dos caches Laravel nativos. **32 controlos novos aprovados**, sem repetir as suites anteriores. Prova e hashes em [proofs/sgf-update-cache-delivery.json](proofs/sgf-update-cache-delivery.json). A candidata é `OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba`, com [ZIP de revisão](../storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba.zip); SHA-256 do ZIP `d14a3261d5aaab80f96250af781c700ab53376021f4638e3aee43c15ff37ceed` e do manifesto `b1ec47f7a9ad87e0b232bac41e402d4a00ee5f03f00a79b93effc692ce8b39a2`. Não foi publicado no canal, instalado ou activado no destino. SGF mantém **0.1.1**.
+
+As pendências históricas de transição entre versões e ensaio dos caches padrão, registadas nas secções seguintes, são sucedidas por este resultado. Não se altera o âmbito das provas anteriores nem se declara concluída a reorganização modular do SGF.
+
+### Transição com proveniência local
+
+`plan` reconhece a versão de código seleccionada pelo registo privado `storage/app/private/module-deployments/current.json`, distinto do ponteiro `packages/purchasing/current.json` que permanece intacto. Confere o journal, os bytes e o manifesto do bundle instalado; rejeita alterações locais, conteúdo extra, bundle sem proveniência, mudança de baseline/composição dos ficheiros nativos ou diferenças nos ficheiros de preparação do schema/catálogo. A transição implementada cobre código sob o **mesmo contrato de schema**. Alterar esse contrato exige plano/revisão de migração próprios; não executa uma migração implicitamente.
+
+`apply` guarda o bundle anterior na instalação, troca o código sob manutenção/serviços parados e actualiza atomicamente o registo privado da versão seleccionada. `rollback` repõe o bundle, ficheiros nativos e proveniência anteriores, sem depender da pasta de Compras, base de desenvolvimento ou ficheiros da origem. Recusa reversão fora de ordem e replay de uma versão que já não está seleccionada. Uma nova tentativa depois de uma reversão recebe outro ID; os journals anteriores permanecem preservados. A repetição de uma reversão já concluída não volta a alterar código/caches da versão anterior.
+
+O registo privado é criado pela rotina actual. Uma cópia antiga/manual de bundle, sem esse registo, não é adoptada automaticamente: exige reconciliação explícita da proveniência. Este requisito preserva os ficheiros existentes e não atribui direitos funcionais. Continua a não haver outorga automática, alteração do perfil ou importação de dados de desenvolvimento.
+
+### Compilação nativa e recuperação de caches
+
+A CLI tem agora a acção `cache`, que usa o mesmo PHP fixado para executar **`config:cache`, `route:cache` e `event:cache` no próprio host**, com manutenção/serviços parados. Arquiva os caches anteriores em armazenamento privado, invalida-os antes de arrancar Artisan e regista comandos/resultado/hashes dos ficheiros produzidos, incluindo resultados parciais quando uma compilação falha. Assim, um `config.php` truncado não impede a CLI de o preservar e reconstruir a configuração a partir das fontes actuais. Os logs e cópias compiladas ficam privados e não são incluídos no ZIP cliente.
+
+Se tiver havido compilação registada, a reversão invalida esses caches e conserva-os em arquivo privado; não repõe silenciosamente valores compilados antigos sobre configuração posterior. Depois de repor o bundle anterior, executar `cache` para o ID dessa versão e reiniciar os seus serviços antes de reabrir. Se não tiver havido recompilação, mantém-se a reposição exacta dos caches anteriores já ensaiada. Alterações aos caches após a compilação registada continuam a bloquear uma reversão cega e exigem reconciliação. Caches com caminhos personalizados continuam fora do contrato actual e são recusados antes de aplicação/recuperação.
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-code-deploy.php" plan "" ""
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-code-deploy.php" apply "" "" "" --offline
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-code-deploy.php" cache "" "" --offline
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-code-deploy.php" rollback "" "" --offline
+```
+
+Para uma actualização: conferir a origem aprovada, os hashes e o plano; preservar o destino e parar os serviços em manutenção; aplicar o bundle; compilar os caches para o ID devolvido; reiniciar apenas os serviços dessa instalação e conferir o seu arranque; reabrir só pela decisão de activação separada. Para reverter: parar serviços em manutenção, reverter o ID seleccionado, compilar os caches da versão anterior reposta e reiniciar/conferir antes de reabrir. Dados/schema/permissões não são restaurados por estes comandos. A preparação de schema continua no executor separado, apenas quando houver uma migração prevista e ensaiada.
+
+### Âmbito exacto das novas provas
+
+Ensaiada transição real entre a candidata anterior `10dce6cd57fa1f31` e a nova, recuperação de falha entre preservação/troca dos bundles, reversão e nova tentativa com histórico distinto. Confirmadas recusas de origem desconhecida, schema divergente, alteração local/conteúdo extra e reversão fora de ordem. A segunda cópia inclui aplicação e dependências, sem `.env` ou dados reais; para acelerar o ensaio, as dependências imutáveis usam hardlinks **apenas para uma cópia descartável anterior**, nunca para o vendor do SGF de destino. Não se executam comandos Composer que alterem essas dependências.
+
+Na cópia isolada foram executados os três comandos de cache nativos, falha injectada após `config:cache`, recuperação de cache inválido pela CLI e reinícios efectivos do servidor PHP local de teste. A rota em cache manteve autenticação (401 para anónimo), manutenção manteve bloqueio (503) e o host voltou a responder após reversão, sem a rota retirada (404) e com healthcheck 200. Os reinícios cobrem o servidor de teste, **não certificam todos os gestores de serviços de clientes**. Nenhum serviço, base, direito público ou ponteiro do SGF de destino foi alterado. Nenhuma contratação para terceiros foi incluída.
+
+O ZIP contém código/manifesto locais com integridade conferida, sem base, credenciais, documentos, cache de cliente ou dependências copiadas de desenvolvimento. Pode ser transportado e extraído para uma pasta de preparação no cliente; conferir hashes e executar as ferramentas pelo PHP do próprio SGF. Não extrair directamente sobre a raiz do host. A aprovação do canal de confiança e a decisão de instalar/activar continuam separadas; a preparação do ZIP não é publicação ou autorização de implantação.
+
+## Aplicação e reversão offline do código — 02/11/2026
+
+Implementadas `integrations/sgf/operational/CodeDeployment.php` e CLI `scripts/sgf-code-deploy.php`, incluídas na candidata `OPERATIONAL-CANDIDATE-10dce6cd57fa1f31`. **42 controlos novos aprovados** com PHP 8.4.27 fixado do SGF, apenas sobre cópias descartáveis de ficheiros; sem abrir bases, reiniciar serviços ou repetir suites anteriores. Ver [proofs/sgf-code-deploy-delivery.json](proofs/sgf-code-deploy-delivery.json). O SGF instalado continua em **0.1.1** e nenhuma aplicação/reversão foi executada nesse destino.
+
+A rotina distingue `plan` read-only, `apply` e `rollback`. Confere manifesto e conteúdo, hashes anteriores dos ficheiros do host e impressão do destino, mantém lock exclusivo e prepara cópias anteriores em `storage/app/private/module-deployments/`. O journal durável é escrito antes de começar a substituir código; cada ficheiro é preparado e trocado por rename no seu directório e o bundle é preparado separadamente antes da troca. **Isto não é uma transacção de filesystem sobre todos os ficheiros:** a consistência visível exige manutenção e serviços de escrita parados durante toda a implantação/recuperação. Uma interrupção deixa o journal para recuperação explícita, sem reabrir o serviço automaticamente.
+
+`apply` exige manutenção Laravel baseada em `storage/framework/down` e `--offline`, que é a declaração do operador de que parou web/workers/schedulers responsáveis por escritas. A rotina não detecta nem termina processos. Guarda os caches padrão `config.php`, `events.php`, `packages.php`, `services.php` e `routes*.php` existentes em `bootstrap/cache`, invalida-os e preserva outros ficheiros. Os backups de configuração compilada podem conter segredos e ficam exclusivamente no armazenamento privado local, com permissões restritas do sistema; não integram o artefacto distribuído. Caches configurados por `APP_*_CACHE` fora das localizações padrão são recusados antes da aplicação: não são apagados nem presumidos cobertos.
+
+`rollback` confere código, backup e caches antes de restaurar; recusa alterações locais desconhecidas e caches recriados, em vez de sobrescrever trabalho ou configuração posterior. Repõe os bytes anteriores dos ficheiros/caches e retira o bundle para arquivo privado de diagnóstico. Recupera igualmente aplicação interrompida a meio dos ficheiros ou depois da troca do bundle. Não apaga schema, dados, recibos de negócio, permissões ou operações posteriores. Conserva o ponteiro `packages/purchasing/current.json` e deixa manutenção activa. A existência de `restart_required=true` é uma obrigação operacional, não prova de reinício automático.
+
+### Sequência de implantação, após decisão separada
+
+1. Produzir/conferir a candidata e manifesto por canal de confiança. Executar `plan` de schema e `plan` de código e registar as duas impressões de destino. Conferir o destino e preservar dados/documentos/configuração. A rotina de código não substitui o backup/restauro de PostgreSQL.
+2. Entrar em manutenção pelo procedimento do host (`artisan down`, driver de ficheiro) e parar os serviços de escrita daquela instalação. Não usar comandos de demonstração que também parem outras instalações. Não executar `apply` com serviços em funcionamento.
+3. Executar a preparação de schema `sgf-internal-install.php migrate`, com backup/manifesto/destino conferidos, conforme secção própria. Nenhuma concessão é atribuída por essa preparação.
+4. Executar `sgf-code-deploy.php apply` com o hash do manifesto, impressão do plano de código, operador nominativo e `--offline`. Registar `deployment_id`. A rotina confere e aplica apenas código/bundle e invalida caches conhecidos; não activa configuração comercial nem concede direitos.
+5. Mantendo a manutenção, conferir os ficheiros e o journal, configurar explicitamente o destino/âmbitos e os acessos aprovados pelo percurso administrativo, e reiniciar os processos persistentes pelo gestor de serviços daquela instalação para descartar código/opcache antigo. A atribuição administrativa que requer HTTP terá de usar uma janela controlada depois do arranque; não contornar os middlewares nem inserir concessões por SQL para evitar essa etapa. Remover a manutenção e abrir aos utilizadores só depois de conferir a instalação e da decisão de activação. Estas acções de serviços/configuração/abertura são do operador e não são executadas pela CLI.
+6. Em falha de código, manter/parar novamente os serviços em manutenção, executar `rollback` com o ID registado, conferir a reposição, reiniciar os serviços e só então decidir reabrir. Se houver código ou caches alterados depois da aplicação, a rotina recusa a recuperação: preservar esses estados e reconciliar explicitamente antes de tentar novamente. Não executar `down` de migrações nem restaurar backup de dados por efeito de rollback de código.
+
+CLI pelo PHP fixado do host; exemplos com parâmetros, **não executados no SGF de destino**:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-code-deploy.php" plan "" ""
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-code-deploy.php" apply "" "" "" --offline
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-code-deploy.php" rollback "" "" --offline
+```
+
+**Limites exactos:** esta versão cobre primeira instalação sobre o baseline conferido e recusa um bundle já existente ou ficheiros nativos divergentes. Ainda não implementa transição entre versões do novo bundle; o updater anterior de código 0.1.1 conserva apenas o seu âmbito já demonstrado. Os controlos de caches deste incremento verificam invalidação e restauro byte a byte dos ficheiros padrão, não regeneração de todos os caches em Laravel nem automação de gestores de serviços. A prova HTTP anterior de 27 controlos mantém o seu âmbito; não foi repetida sobre cada combinação de caches. Não declarar disponibilização comercial completa nem aceitar actualizações futuras sem a respectiva transição/compatibilidade. A reorganização modular permanece entrega distinta.
+
## Arranque e integração HTTP em cópia isolada — 02/11/2026
Preparada a candidata `OPERATIONAL-CANDIDATE-4b25f81932efd69d`, preservando os artefactos anteriores. A composição já inclui sobreposição do provider nativo para carregar o bundle local e registar as rotas, sem registo manual de rotas pelo harness HTTP. O bundle fica em `packages/internal-requisitions`, com classes e ficheiros próprios; a sobreposição do SGF reconhece `purchasing_requisitions.view` como autorização de descoberta do módulo Compras, sem conceder direitos nem substituir o catálogo/controlo funcional.
@@ -40,10 +208,11 @@ Estados: **Transferido** identifica a capacidade exacta instalada e executada no
| 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 |
+| Aplicação/actualização/reversão do código da nova integração | **Parcialmente transferido — candidata não instalada** | Journal/backups e registo privado da versão, lock, preservação do bundle anterior; sem efeitos de dados/schema | CLI `plan/apply/cache/rollback`, com proveniência e contrato de schema iguais; serviços parados pelo operador | Transição, falhas e reversão ensaiadas; caches nativos compilados e servidor isolado reiniciado; nenhuma abertura automática no cliente | 32 controlos novos em `sgf-update-cache-delivery.json`, além das provas anteriores; caches personalizados e migrações futuras exigem plano próprio; destino 0.1.1 |
| 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 |
+| Navegação modular do SGF, entrega própria | **Tecnicamente aceite — 02/11/2026** | Seis módulos, Início/Opções gerais separados; não equivale a todos os processos comerciais concluídos | Provas SGF próprias de contexto, autorização e perfis sintéticos; sem outorga adicional por escolha de módulo | Revisão visual/recargas, associações antigas e volume/paginação mantêm pendências próprias | Aceitação expressa em `MODULAR_NAVIGATION_DELIVERY/COMPLETION`; 185 testes/3.220 asserções preservados; não autoriza a instalação da candidata |
| 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 e identidade independente; composição no provider e associação do novo direito de consulta ao módulo preparadas | Ecrãs e percurso integrados na cópia isolada pelo arranque nativo; nenhuma rota/menu novo activo no destino | Provas anteriores preservadas; novo ensaio de composição HTTP em `sgf-composition-delivery.json`; aplicação operacional do código/caches e equipamentos/peças 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 |
@@ -244,3 +413,13 @@ O manifesto exige `database_changes=false` e o formato não aceita pastas de mig
- 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.
+
+## 02/11/2026 — candidata utilizável SGF, sem instalação
+
+Preparada `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20` (schema revisão 2); ZIP SHA-256 `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703`, manifesto `a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b`. Não substituir por candidata anterior nem declarar release comercial nova.
+
+Resolve cb_ui_commands/cb_consumption_sources, ecrã nativo de Acessos ligado ao catálogo, centros/dotações pela interface e aprovação independente. Assets compilados incluídos no bundle; nove overlays exactos com baselines, sem escrita no código instalado. Mapa SQL mínimo separado, revogando defaults excessivos apenas nos objectos novos; não aplicado ao destino. Nenhum utilizador real recebe direitos.
+
+Reprodução schema-only do SGF actual (87 tabelas, zero linhas exportadas), roles sintéticas restritas separadas: 157 controlos integrados; 17 controlos React/render/assets; 54 aplicação/interrupção/recuperação. Backup restaurado em segunda base, 103 tabelas reconciliadas e origem/reserva/decisão preservadas. Actualização de código compatível e rollback conferidos sem alterações de dados. Bases/roles de prova removidas. Provas em docs/proofs/sgf-corrected-delivery.json e sgf-code-deploy-delivery.json; prova consolidada e proposta vinculativa em `C:/laragon/www/sgf-tempo/docs/PURCHASING_INSTALLATION_PROPOSAL.md`.
+
+Não houve instalação/activação/publicação; SGF mantém 0.1.1 e sgf_tempo_dev/direitos públicos preservados. Equipamentos/peças excluídos; básico consumíveis/serviços/outros provado. Revisão visual, associações antigas e volume/paginação continuam pendentes. Código funcional modular a preservar: 2a4169162bb4621a42a2ea1df627d52f860e7176; fcf0ada é documental. Instalação e configuração real dependem de decisão posterior; nunca confundir bundles anteriores com recuperação de dados.
diff --git a/docs/warehouse-unused-return.md b/docs/warehouse-unused-return.md
new file mode 100644
index 0000000..94ff463
--- /dev/null
+++ b/docs/warehouse-unused-return.md
@@ -0,0 +1,17 @@
+# Devolução de excedente ao armazém
+
+02/11/2026 — `NATIVE-WAREHOUSE-UNUSED-RETURN-SIM-v1`, implementação local sintética, desactivada por defeito (`config/stock_returns.php`). Não instala candidatas, não altera dados/acessos de demonstrações e não requer SGF remoto.
+
+A requisição interna já autorizada/entregue/recebida fornece a origem. O destinatário original selecciona a linha e declara quantidade não utilizada, evidência e fundamento; não redigita artigo/unidade/armazém. A prévia mostra entrega ainda não devolvida e efeito físico. Outra identidade, com competência de confirmação e armazém, declara a recepção física. Versão, competências, contexto, origem, unidade e livro são conferidos novamente sob bloqueio da empresa. Alterações exigem nova conferência. A retirada pelo preparador e recusa independente pelo armazém encerram a proposta sem efeito físico; não apagam a proposta.
+
+Escrita única pelo artefacto local `packages/inventory-return/releases/0.1.0`, manifesto `2850a4f5cb678acfe3a9dbc7a7d62eb233bc72722ad0de374681c2679c90557c`, verificado em execução. Operação/movimento/decisão/auditoria são atómicos; repetição devolve o efeito original. Reutiliza a sinalização de `AutomaticPurchases` uma vez dentro da transacção, sem novas regras/aprovações/encomendas. Reversão integral da saída com devolução ligada é recusada para evitar segundo crédito.
+
+Competências configuráveis existentes em `OrganizationAccess`: `warehouse_return_read`, `warehouse_return_prepare`, `warehouse_return_confirm` (confirmação/recusa requer também `warehouse`). Sem concessão automática por função/nome. Somente perfis sintéticos na base própria receberam estas competências; nenhumas concessões no piloto ou reais. Interface `/stock/devolucoes-internas`, ligações condicionais em requisição/histórico, filtros e páginas de 25. Não acrescenta a operação ao formulário genérico de movimentos.
+
+Provas: 19 casos nativos distintos / 440 asserções, mais reversões afectadas 16/81 e observação SGF somente de leitura 1/23; **36/544 no total**, sem somar repetições. Recepção real do workflow 10, entrega 5, devolução 3, saldo 8, três movimentos. Concorrência com processos reais verifica espera e repetição única, recusa 409 por quantidade esgotada, retirada/confirmação em ambas as ordens. Limite HTTP local produz 423 sem efeitos; repetição explícita depois da libertação confirma. Falha transaccional e indisponibilidade do artefacto recuperam sem duplicação. O histórico de 51 propostas tem páginas disjuntas e três SELECTs centrais por página de 25, com leitura conjunta dos preparadores.
+
+Relatório e hashes: `C:/laragon/www/sgf-tempo/docs/proofs/native-warehouse-return-20261102.json`; descrição detalhada: `C:/laragon/www/sgf-tempo/docs/NATIVE_WAREHOUSE_RETURN_DELIVERY.md`. Bases QA guardadas em PostgreSQL 55439, sem reset. O observador SGF usa fixture arquivada e preservada; não significa instalação operacional nem obrigação de ligação do SGC autónomo ao SGF.
+
+PHPStan dirigido passou; tipos/bundle das páginas e renderização no servidor do histórico HTTP passaram. Build integral privado passou com configuração QA sem fontes remotas/React Compiler opcional; build optimizado predefinido não validado. Revisão visual, interacção no navegador, carga de escrita e implantação pendentes.
+
+Limites: apenas entrega interna, sem lotes, reservas, equipamento/peças ou qualidade/quarentena. Quantidade não utilizada é declaração do destinatário, não consumo técnico verificado pelo SGF. Não corrige material consumido ou devoluções já confirmadas. O histórico de planeamento mantém saídas brutas `issue`: 5 entregues/3 devolvidas mantém média de 5, saldo físico 8 e novo hash da origem. Previsão por consumo líquido e a sua atribuição temporal não foram implementadas; não reescrever análises aprovadas por arrasto.
diff --git a/integrations/contracts/warehouse-fulfilment-v1.json b/integrations/contracts/warehouse-fulfilment-v1.json
new file mode 100644
index 0000000..902277c
--- /dev/null
+++ b/integrations/contracts/warehouse-fulfilment-v1.json
@@ -0,0 +1,91 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "SGC internal warehouse fulfilment feed v1",
+ "description": "Committed delivery and receipt facts. Quantity is in thousandths of the original item unit. This feed is not a stock replication command or current balance projection.",
+ "type": "object",
+ "required": ["schema", "items", "has_more", "next_cursor"],
+ "properties": {
+ "schema": { "const": "sgc.warehouse.fulfilment-feed.v1" },
+ "items": { "type": "array", "maxItems": 100, "items": { "$ref": "#/$defs/event" } },
+ "has_more": { "type": "boolean" },
+ "next_cursor": { "type": "string", "minLength": 1 }
+ },
+ "$defs": {
+ "id": { "type": "integer", "minimum": 1 },
+ "registry": {
+ "type": "object",
+ "required": ["id", "code", "name"],
+ "properties": {
+ "id": { "$ref": "#/$defs/id" },
+ "code": { "type": "string" },
+ "name": { "type": "string" }
+ }
+ },
+ "actor": {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": { "id": { "$ref": "#/$defs/id" }, "name": { "type": "string" } }
+ },
+ "line": {
+ "type": "object",
+ "required": ["request_line_index", "item", "quantity_milli", "use", "destination", "equipment", "stock_operation_id"],
+ "properties": {
+ "request_line_index": { "type": "integer", "minimum": 0 },
+ "item": {
+ "allOf": [
+ { "$ref": "#/$defs/registry" },
+ { "required": ["unit"], "properties": { "unit": { "type": "string" } } }
+ ]
+ },
+ "quantity_milli": { "type": "integer", "minimum": 1 },
+ "use": { "enum": ["internal", "equipment"] },
+ "destination": { "type": "string" },
+ "equipment": {
+ "oneOf": [
+ { "type": "null" },
+ {
+ "type": "object", "required": ["id", "code", "description"],
+ "properties": { "id": { "$ref": "#/$defs/id" }, "code": { "type": "string" }, "description": { "type": "string" } }
+ }
+ ]
+ },
+ "stock_operation_id": { "$ref": "#/$defs/id" }
+ }
+ },
+ "event": {
+ "type": "object",
+ "required": ["schema", "idempotency_key", "tenant_id", "event_id", "event_version", "action", "occurred_at", "actor", "request", "lines", "delivery_event_key", "payload_sha256"],
+ "properties": {
+ "schema": { "const": "sgc.warehouse.fulfilment-event.v1" },
+ "idempotency_key": { "type": "string", "pattern": "^sgc:[1-9][0-9]*:warehouse-event:[1-9][0-9]*$" },
+ "tenant_id": { "$ref": "#/$defs/id" },
+ "event_id": { "$ref": "#/$defs/id" },
+ "event_version": { "type": "integer", "minimum": 1 },
+ "action": { "enum": ["deliver", "receive"] },
+ "occurred_at": { "type": "string", "format": "date-time" },
+ "actor": { "$ref": "#/$defs/actor" },
+ "request": {
+ "type": "object",
+ "required": ["id", "reference", "document_type", "work_context_key", "requester", "warehouse", "cost_center", "destination"],
+ "properties": {
+ "id": { "$ref": "#/$defs/id" },
+ "reference": { "type": "string", "pattern": "^RA-[1-9][0-9]*$" },
+ "document_type": { "const": "internal_warehouse_request" },
+ "work_context_key": { "enum": ["procurement.internal", null] },
+ "requester": { "$ref": "#/$defs/actor" },
+ "warehouse": { "$ref": "#/$defs/registry" },
+ "cost_center": { "$ref": "#/$defs/registry" },
+ "destination": { "type": "string" }
+ }
+ },
+ "lines": { "type": "array", "maxItems": 50, "items": { "$ref": "#/$defs/line" } },
+ "delivery_event_key": { "type": ["string", "null"] },
+ "payload_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
+ },
+ "oneOf": [
+ { "properties": { "action": { "const": "deliver" }, "lines": { "minItems": 1 }, "delivery_event_key": { "type": "null" } } },
+ { "properties": { "action": { "const": "receive" }, "lines": { "maxItems": 0 }, "delivery_event_key": { "type": "string", "pattern": "^sgc:[1-9][0-9]*:warehouse-event:[1-9][0-9]*$" } } }
+ ]
+ }
+ }
+}
diff --git a/integrations/sgf/access/AccessOverlay.php b/integrations/sgf/access/AccessOverlay.php
index b7b66a9..2728c5b 100644
--- a/integrations/sgf/access/AccessOverlay.php
+++ b/integrations/sgf/access/AccessOverlay.php
@@ -6,6 +6,8 @@ namespace Tempo\Purchasing\Delivery;
final class AccessOverlay
{
public const LABELS = [
+ 'purchasing_sourcing.prepare'=>'Preparar consultas e cotações', 'purchasing_sourcing.evaluate'=>'Avaliar cotações',
+ 'purchasing_sourcing.propose'=>'Propor adjudicação', 'purchasing_sourcing.confirm'=>'Confirmar adjudicação independente',
'purchasing_requisitions.view' => 'Consultar requisições', 'purchasing_requisitions.prepare' => 'Preparar e submeter requisições',
'purchasing_requisitions.configure' => 'Configurar Compras internas', 'purchasing_requisitions.approve.department_head' => 'Aprovar como responsável de departamento',
'purchasing_requisitions.approve.procurement' => 'Aprovar como responsável de Compras', 'purchasing_requisitions.approve.finance' => 'Aprovar como responsável financeiro',
@@ -15,6 +17,7 @@ final class AccessOverlay
];
public const RIGHTS = [
+ 'purchasing_sourcing.prepare','purchasing_sourcing.evaluate','purchasing_sourcing.propose','purchasing_sourcing.confirm',
'purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure',
'purchasing_requisitions.approve.department_head', 'purchasing_requisitions.approve.procurement', 'purchasing_requisitions.approve.finance',
'purchasing_budget.create', 'purchasing_budget.approve', 'purchasing_budget.record', 'purchasing_budget.consume', 'purchasing_budget.view',
diff --git a/integrations/sgf/internal-requisitions/Controller.php b/integrations/sgf/internal-requisitions/Controller.php
index d2ddbfd..d771fd2 100644
--- a/integrations/sgf/internal-requisitions/Controller.php
+++ b/integrations/sgf/internal-requisitions/Controller.php
@@ -89,7 +89,7 @@ final class Controller
$record = $port->load($id);
$port->authorize('view', $record);
- return ['record' => $this->project($port, $record), 'history' => $port->query('events')->where('request_id', $id)->orderBy('version')->get()];
+ return ['record' => $this->project($port, $record), 'history' => $port->query('events')->where('request_id', $id)->orderBy('version')->get(), 'canSourcing'=>config('purchasing-sourcing.enabled')===true&&$record['status']==='approved'];
});
return response()->json($result)->header('Cache-Control', 'private, no-store');
@@ -159,7 +159,7 @@ final class Controller
$selection = $request->session()->get('company_context', []);
$scope = $port->scope();
$contract = ['csrf' => csrf_token(), 'token' => $selection['token'] ?? '', 'scope' => $scope, 'context' => 'procurement.internal', 'actor_id' => $port->actorId(),
- 'company' => DB::table('companies')->where('id', $scope['company_id'])->value('name'), 'unit' => DB::table('units')->where('id', $scope['unit_id'])->value('name')];
+ 'company' => DB::table('companies')->where('id', $scope['company_id'])->value('name'), 'unit' => DB::table('units')->where('id', $scope['unit_id'])->value('name'), 'canBudgetSetup' => app(Permissions::class)->has($port->actorId(), $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.view', $scope['unit_id'])];
ob_start();
require __DIR__.'/screen.php';
$html = ob_get_clean();
diff --git a/integrations/sgf/internal-requisitions/Port.php b/integrations/sgf/internal-requisitions/Port.php
index 4673a71..1f8d060 100644
--- a/integrations/sgf/internal-requisitions/Port.php
+++ b/integrations/sgf/internal-requisitions/Port.php
@@ -121,7 +121,9 @@ final class Port implements RequisitionPort
if ($record !== null) {
abort_unless($record['work_context'] === 'procurement.internal', 403);
$all = $this->has('configure') || $this->has('approve.procurement') || $this->has('approve.finance');
- $visible = $all || $record['requester_id'] === $this->actor->id || ($this->has('approve.department_head') && $record['department_ref'] === $this->department());
+ // Scoped view is sufficient for reading; it never grants mutation rights.
+ // load()/query() and atomic() retain tenant, company, unit and option checks.
+ $visible = $action === 'view' || $all || $record['requester_id'] === $this->actor->id || ($this->has('approve.department_head') && $record['department_ref'] === $this->department());
abort_unless($visible, 404);
}
if (in_array($action, ['save', 'submit'], true)) {
diff --git a/integrations/sgf/internal-requisitions/screen.mjs b/integrations/sgf/internal-requisitions/screen.mjs
index 9b6288b..2da6432 100644
--- a/integrations/sgf/internal-requisitions/screen.mjs
+++ b/integrations/sgf/internal-requisitions/screen.mjs
@@ -191,6 +191,10 @@ if (typeof document !== 'undefined') {
$('detail-title').textContent =
`${selected.number} · ${selected.status} · Versão ${selected.version}`;
$('detail-data').replaceChildren();
+ if(result.canSourcing){
+ const link=el('a','Consulta de fornecedores, cotações e adjudicação',$('detail-data'));
+ link.href='/purchasing/sourcing/'+selected.id;
+ }
el('p', selected.data.justification, $('detail-data'));
el(
'p',
diff --git a/integrations/sgf/internal-requisitions/screen.php b/integrations/sgf/internal-requisitions/screen.php
index 2d92175..5e50c1e 100644
--- a/integrations/sgf/internal-requisitions/screen.php
+++ b/integrations/sgf/internal-requisitions/screen.php
@@ -1,7 +1,7 @@
Requisições internas · SGF
-
+
Pedidos autorizados Actualizar Nova requisição
Etapas de aprovação
Histórico por versão
diff --git a/integrations/sgf/operational/AdminUiOverlay.php b/integrations/sgf/operational/AdminUiOverlay.php
new file mode 100644
index 0000000..2f53e97
--- /dev/null
+++ b/integrations/sgf/operational/AdminUiOverlay.php
@@ -0,0 +1,44 @@
+ ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]];";
+ $contents = $files[$workflow]['contents'];
+ if (substr_count($contents, $old) !== 1) {
+ throw new \RuntimeException('Catálogo administrativo do host divergiu.');
+ }
+ $contents = str_replace($old, "'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT], 'catalogue_labels' => \\Tempo\\Purchasing\\Delivery\\AccessOverlay::LABELS];", $contents);
+ $files[$workflow]['contents'] = $contents;
+ $files[$workflow]['after_sha256'] = hash('sha256', $contents);
+ $node = getenv('SGF_NODE_BINARY') ?: 'node';
+ $process = proc_open([$node, dirname(__DIR__, 3).'/scripts/sgf-admin-ui-build.mjs', $host], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (! is_resource($process)) {
+ throw new \RuntimeException('Não foi possível preparar a interface.');
+ }
+ fclose($pipes[0]);
+ $output = stream_get_contents($pipes[1]);
+ $error = stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ if (proc_close($process) !== 0) {
+ throw new \RuntimeException('Frontend candidato: '.$error);
+ }
+ $metadata = json_decode(file_get_contents(json_decode($output, true, 16, JSON_THROW_ON_ERROR)['metadata']), true, 32, JSON_THROW_ON_ERROR);
+ foreach ($metadata['overlays'] as $file => $source) {
+ $body = file_get_contents($source);
+ $files[$file] = ['before_sha256' => hash_file('sha256', $host.'/'.$file), 'after_sha256' => hash('sha256', $body), 'contents' => $body];
+ }
+ $assets = [];
+ foreach ($metadata['assets'] as $file => $source) {
+ $assets[$file] = file_get_contents($source);
+ }
+
+ return ['files' => $files, 'assets' => $assets];
+ }
+}
diff --git a/integrations/sgf/operational/BudgetOverlay.php b/integrations/sgf/operational/BudgetOverlay.php
index c58bdb9..1029f9c 100644
--- a/integrations/sgf/operational/BudgetOverlay.php
+++ b/integrations/sgf/operational/BudgetOverlay.php
@@ -16,6 +16,19 @@ final class BudgetOverlay
}
$source = str_replace($anchor, $anchor."\n if (config('purchasing-operational.enabled') === true) {\n \\Tempo\\Purchasing\\Delivery\\Installation::assertBudgetReady(DB::connection());\n return;\n }", $source);
- return [$path => ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source]];
+ $result = [$path => ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source]];
+ $path = 'app/Modules/Purchasing/BudgetWorkspace.php';
+ $source = file_get_contents($host.'/'.$path);
+ $before = hash('sha256', $source);
+ $anchor = 'public function command(User $user, string $action, array $input, string $key): array'."\n {";
+ $source = str_replace("\r\n", "\n", $source);
+ if (substr_count($source, $anchor) !== 1) {
+ throw new \RuntimeException('Workspace orçamental divergente.');
+ }
+ $source = str_replace($anchor, $anchor."\n if (config('purchasing-operational.enabled') === true) { abort_unless(in_array(\$action, ['create', 'approve'], true), 403, 'Movimentos orçamentais operacionais pertencem ao processo de origem.'); }", $source);
+ $source = str_replace("'synthetic_only' => true", "'synthetic_only' => config('purchasing-operational.enabled') !== true", $source);
+ $result[$path] = ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source];
+
+ return $result;
}
}
diff --git a/integrations/sgf/operational/BudgetSetup.php b/integrations/sgf/operational/BudgetSetup.php
new file mode 100644
index 0000000..59f9c32
--- /dev/null
+++ b/integrations/sgf/operational/BudgetSetup.php
@@ -0,0 +1,58 @@
+validate(['operation_key' => 'required|uuid', 'code' => ['required', 'string', 'max:100', 'regex:/^[\pL\pN][\pL\pN ._\/-]*$/uD'], 'reason' => 'required|string|min:10|max:500']);
+ abort_unless(array_diff(array_keys($request->all()), array_keys($data)) === [], 422);
+ $port = new Port($request->user());
+ $result = $port->atomic(function () use ($port, $data) {
+ $port->authorize('configure', null);
+ $scope = $port->scope();
+ app(Permissions::class)->require($port->actorId(), $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.create', $scope['unit_id']);
+ $hash = hash('sha256', json_encode([$data['code'], $data['reason']], JSON_THROW_ON_ERROR));
+ $receipts = DB::table('cb_ui_commands')->where($scope)->where('actor_id', $port->actorId())->where('command_key', $data['operation_key']);
+ if ($old = $receipts->first()) {
+ abort_unless($old->action === 'centre.create' && hash_equals($old->request_hash, $hash), 409);
+
+ return json_decode($old->result, true, 16, JSON_THROW_ON_ERROR) + ['replayed' => true];
+ }
+ abort_if(DB::table('cb_centres')->where($scope)->where('code', $data['code'])->exists(), 409, 'Centro já cadastrado; seleccione a fonte existente.');
+ $id = DB::table('cb_centres')->insertGetId($scope + ['code' => $data['code']]);
+ $result = ['centre_id' => $id, 'code' => $data['code']];
+ DB::table('cb_audit')->insert($scope + ['actor_id' => $port->actorId(), 'action' => 'centre.created', 'origin_id' => null, 'payload' => json_encode($result + ['reason' => $data['reason']], JSON_THROW_ON_ERROR), 'created_at' => now()]);
+ DB::table('cb_ui_commands')->insert($scope + ['actor_id' => $port->actorId(), 'command_key' => $data['operation_key'], 'action' => 'centre.create', 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
+
+ return $result;
+ });
+
+ return response()->json($result)->header('Cache-Control', 'private, no-store');
+ }
+
+ public function page(Request $request)
+ {
+ Installation::assertBudgetReady(DB::connection());
+ $ctx = app(CompanyContext::class);
+ $scope = $ctx->identifiers() + ['unit_id' => $ctx->unitId()];
+ $p = app(Permissions::class);
+ $actor = $request->user()->id;
+ $p->require($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.view', $scope['unit_id']);
+ $contract = ['csrf' => csrf_token(), 'token' => $request->session()->get('company_context.token'), 'scope' => $scope, 'actor_id' => $actor,
+ 'canCentre' => $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.configure', $scope['unit_id']) && $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.create', $scope['unit_id']),
+ 'canPrepare' => $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.create', $scope['unit_id']),
+ 'canApprove' => $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.approve', $scope['unit_id'])];
+ ob_start();
+ require __DIR__.'/budget-setup.php';
+
+ return response(ob_get_clean())->header('Cache-Control', 'private, no-store');
+ }
+}
diff --git a/integrations/sgf/operational/BudgetUiSchema.php b/integrations/sgf/operational/BudgetUiSchema.php
new file mode 100644
index 0000000..7138c89
--- /dev/null
+++ b/integrations/sgf/operational/BudgetUiSchema.php
@@ -0,0 +1,52 @@
+getSchemaBuilder();
+ foreach (self::TABLES as $table) {
+ if ($schema->hasTable($table)) {
+ throw new \RuntimeException('Dependência de interface existente exige reconciliação explícita.');
+ }
+ }
+ $scope = function (Blueprint $t) {
+ foreach (['tenant_id', 'company_id', 'unit_id'] as $name) {
+ $t->unsignedBigInteger($name);
+ }
+ $t->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete();
+ };
+ $schema->create('cb_ui_commands', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->unsignedBigInteger('actor_id');
+ $t->foreign(['actor_id', 'tenant_id', 'company_id'])->references(['user_id', 'tenant_id', 'company_id'])->on('company_memberships')->restrictOnDelete();
+ $t->uuid('command_key');
+ $t->string('action');
+ $t->string('request_hash', 64);
+ $t->jsonb('result');
+ $t->timestamp('created_at');
+ $t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'command_key']);
+ });
+ $schema->create('cb_consumption_sources', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('origin_id')->constrained('cb_origins');
+ $t->foreignId('created_by')->constrained('users');
+ $t->string('reference', 100);
+ $t->bigInteger('total_minor');
+ $t->bigInteger('subtotal_minor');
+ $t->timestamp('created_at');
+ $t->unique(['tenant_id', 'company_id', 'unit_id', 'reference']);
+ });
+ $db->unprepared("CREATE FUNCTION cb_ui_commands_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve budget command receipts'; END; \$\$; CREATE TRIGGER cb_ui_commands_immutable BEFORE UPDATE OR DELETE ON cb_ui_commands FOR EACH ROW EXECUTE FUNCTION cb_ui_commands_immutable();");
+ }
+}
diff --git a/integrations/sgf/operational/CodeDeployment.php b/integrations/sgf/operational/CodeDeployment.php
new file mode 100644
index 0000000..d67a83b
--- /dev/null
+++ b/integrations/sgf/operational/CodeDeployment.php
@@ -0,0 +1,548 @@
+host = str_replace('\\', '/', realpath($host) ?: throw new \RuntimeException('Host inexistente.'));
+ }
+
+ private function path(string $relative): string
+ {
+ if ($relative === '' || str_contains($relative, '..') || ! preg_match('~^[A-Za-z0-9_./-]+$~D', $relative)) {
+ throw new \RuntimeException('Caminho de implantação inválido.');
+ }
+ $path = $this->host.'/'.$relative;
+ $part = $this->host;
+ foreach (explode('/', $relative) as $segment) {
+ $part .= '/'.$segment;
+ if (is_link($part)) {
+ throw new \RuntimeException('Links recusados na implantação.');
+ }
+ }
+ $parent = $path;
+ while (! file_exists($parent)) {
+ $parent = dirname($parent);
+ }
+ $resolved = str_replace('\\', '/', realpath($parent) ?: '');
+ if ($resolved !== $this->host && ! str_starts_with(strtolower($resolved), strtolower($this->host).'/')) {
+ throw new \RuntimeException('Caminho fora do host.');
+ }
+
+ return $path;
+ }
+
+ private static function write(string $path, string $contents): void
+ {
+ if (! is_dir(dirname($path)) && ! mkdir(dirname($path), 0700, true)) {
+ throw new \RuntimeException('Falha no directório de implantação.');
+ }
+ $temp = $path.'.'.bin2hex(random_bytes(6)).'.next';
+ $stream = fopen($temp, 'xb');
+ if (! $stream) {
+ throw new \RuntimeException('Falha na preparação do ficheiro.');
+ }
+ chmod($temp, 0600);
+ try {
+ $written = 0;
+ while ($written < strlen($contents)) {
+ $n = fwrite($stream, substr($contents, $written));
+ if ($n === false || $n === 0) {
+ throw new \RuntimeException('Escrita incompleta.');
+ }
+ $written += $n;
+ }
+ fflush($stream);
+ if (function_exists('fsync')) {
+ fsync($stream);
+ }
+ } finally {
+ fclose($stream);
+ }
+ if (! rename($temp, $path)) {
+ throw new \RuntimeException('Falha na troca do ficheiro.');
+ }
+ }
+
+ private function manifest(string $bundle, string $expected): array
+ {
+ if (trim($bundle) === '') {
+ throw new \RuntimeException('Bundle explícito obrigatório.');
+ }
+ $bundle = realpath($bundle) ?: throw new \RuntimeException('Bundle inexistente.');
+ $text = file_get_contents($bundle.'/manifest.json');
+ if (! hash_equals($expected, hash('sha256', $text))) {
+ throw new \RuntimeException('Manifesto divergente.');
+ }
+ $manifest = json_decode($text, true, 32, JSON_THROW_ON_ERROR);
+ if (($manifest['format'] ?? '') !== 'tempo.operational-review.v1') {
+ throw new \RuntimeException('Formato de entrega desconhecido.');
+ }
+ $expectedFiles = array_fill_keys([...array_keys($manifest['support_files']), 'manifest.json'], true);
+ foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($bundle, \FilesystemIterator::SKIP_DOTS)) as $entry) {
+ $relative = str_replace('\\', '/', substr($entry->getPathname(), strlen($bundle) + 1));
+ if ($entry->isLink() || ($entry->isFile() && ! isset($expectedFiles[$relative]))) {
+ throw new \RuntimeException('Conteúdo extra ou link no bundle.');
+ }
+ }
+ foreach ($manifest['support_files'] as $file => $hash) {
+ $resolved = str_replace('\\', '/', realpath($bundle.'/'.$file) ?: '');
+ if (str_contains($file, '..') || ! preg_match('~^(bootstrap\.php|(app|packages|integrations|scripts)/[A-Za-z0-9_./-]+|resources/js/(app|PurchasingHome)\.jsx|public/build/manifest\.json)$~D', $file)
+ || ! str_starts_with(strtolower($resolved), strtolower(str_replace('\\', '/', $bundle)).'/') || is_link($bundle.'/'.$file) || ! is_file($bundle.'/'.$file) || ! hash_equals($hash, hash_file('sha256', $bundle.'/'.$file))) {
+ throw new \RuntimeException('Integridade do bundle divergente.');
+ }
+ }
+ foreach ($manifest['host_overlays'] as $file => $hashes) {
+ if ((! str_starts_with($file, 'app/') && ! in_array($file, ['resources/js/app.jsx', 'resources/js/PurchasingHome.jsx', 'public/build/manifest.json'], true)) || ($manifest['support_files'][$file] ?? null) !== $hashes['after_sha256']) {
+ throw new \RuntimeException('Sobreposição inválida.');
+ }
+ $this->path($file);
+ }
+
+ return [$bundle, $manifest];
+ }
+
+ public function plan(string $bundle, string $manifestHash): array
+ {
+ $this->cachePaths();
+ [, $manifest] = $this->manifest($bundle, $manifestHash);
+ $pointer = $this->path('packages/purchasing/current.json');
+ $pointerHash = hash_file('sha256', $pointer);
+ $previous = $this->current();
+ $oldManifest = null;
+ if (is_dir($this->path('packages/internal-requisitions'))) {
+ if ($previous === null) {
+ throw new \RuntimeException('Bundle sem proveniência de implantação; reconciliar explicitamente.');
+ }
+ $journal = json_decode(file_get_contents($this->path('storage/app/private/module-deployments/'.$previous['deployment_id'].'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
+ if ($journal['state'] !== 'applied') {
+ throw new \RuntimeException('Implantação incompleta exige recuperação.');
+ }
+ $this->verifyApplied($journal, $previous['deployment_id']);
+ [, $oldManifest] = $this->manifest($this->path('packages/internal-requisitions'), $previous['manifest_sha256']);
+ if (array_keys($oldManifest['host_overlays']) !== array_keys($manifest['host_overlays'])) {
+ throw new \RuntimeException('Mudança de composição do host exige plano próprio.');
+ }
+ foreach (['integrations/sgf/operational/BudgetSchema.php', 'integrations/sgf/operational/BudgetUiSchema.php', 'integrations/sgf/operational/SqlPrivileges.php', 'integrations/sgf/operational/Installation.php', 'integrations/sgf/operational/budget-contract.json', 'integrations/sgf/internal-requisitions/Schema.php', 'integrations/sgf/access/AccessOverlay.php'] as $file) {
+ if (($oldManifest['support_files'][$file] ?? null) !== ($manifest['support_files'][$file] ?? null)) {
+ $receiptPath=$this->path('storage/app/private/module-deployments/schema-transition-'.$manifestHash.'.json');
+ $receipt=is_file($receiptPath)?json_decode(file_get_contents($receiptPath),true,32,JSON_THROW_ON_ERROR):[];
+ if(($receipt['from_manifest_sha256']??null)!==$previous['manifest_sha256']||($receipt['to_manifest_sha256']??null)!==$manifestHash||($receipt['host']??null)!==$this->host||($receipt['changes'][$file]['before']??null)!==($oldManifest['support_files'][$file]??null)||($receipt['changes'][$file]['after']??null)!==($manifest['support_files'][$file]??null)||($receipt['schema_verified']??false)!==true){
+ throw new \RuntimeException('Transição de schema exige migração própria e recibo exacto do destino.');
+ }
+ }
+ }
+ } elseif ($previous !== null) {
+ throw new \RuntimeException('Registo activo sem bundle; recuperar a implantação anterior.');
+ }
+ $before = [];
+ foreach ($manifest['host_overlays'] as $file => $hashes) {
+ $hash = hash_file('sha256', $this->path($file));
+ $expected = $oldManifest === null ? $hashes['before_sha256'] : $oldManifest['host_overlays'][$file]['after_sha256'];
+ if ($oldManifest !== null && $hashes['before_sha256'] !== $oldManifest['host_overlays'][$file]['before_sha256']) {
+ throw new \RuntimeException('Baseline do host mudou entre versões.');
+ }
+ if ($hash !== $expected) {
+ throw new \RuntimeException('Host alterado; reconciliar antes de implantar.');
+ }
+ $before[$file] = $hash;
+ }
+ $target = hash('sha256', json_encode([$this->host, $pointerHash, $before, $previous], JSON_THROW_ON_ERROR));
+
+ return ['target_sha256' => $target, 'pointer_sha256' => $pointerHash, 'host_files' => $before, 'manifest_sha256' => $manifestHash, 'previous' => $previous, 'deployment_id' => $this->attempt($manifestHash), 'writes' => false];
+ }
+
+ private function current(): ?array
+ {
+ $path = $this->path('storage/app/private/module-deployments/current.json');
+ if (! is_file($path)) {
+ return null;
+ }
+ $value = json_decode(file_get_contents($path), true, 16, JSON_THROW_ON_ERROR);
+ if (! preg_match('/^[a-f0-9]{24}$/D', $value['deployment_id'] ?? '') || ! preg_match('/^[a-f0-9]{64}$/D', $value['manifest_sha256'] ?? '')) {
+ throw new \RuntimeException('Registo activo inválido.');
+ }
+
+ return $value;
+ }
+
+ private function attempt(string $hash): string
+ {
+ for ($i = 0; $i < 1000; $i++) {
+ $id = substr($i === 0 ? $hash : hash('sha256', $hash.':'.$i), 0, 24);
+ $file = $this->path('storage/app/private/module-deployments/'.$id.'/journal.json');
+ if (! is_file($file) || json_decode(file_get_contents($file), true, 32, JSON_THROW_ON_ERROR)['state'] !== 'rolled-back') {
+ return $id;
+ }
+ }
+ throw new \RuntimeException('Reconciliar histórico de tentativas.');
+ }
+
+ private function offline(bool $stopped): void
+ {
+ $this->cachePaths();
+ if (! $stopped || ! is_file($this->path('storage/framework/down'))) {
+ throw new \RuntimeException('Exige manutenção activa e serviços de escrita parados.');
+ }
+ if (! is_array(json_decode(file_get_contents($this->path('storage/framework/down')), true))) {
+ throw new \RuntimeException('Registo de manutenção inválido.');
+ }
+ }
+
+ private function cachePaths(): void
+ {
+ $expected = ['APP_CONFIG_CACHE' => 'bootstrap/cache/config.php', 'APP_EVENTS_CACHE' => 'bootstrap/cache/events.php', 'APP_PACKAGES_CACHE' => 'bootstrap/cache/packages.php', 'APP_SERVICES_CACHE' => 'bootstrap/cache/services.php', 'APP_ROUTES_CACHE' => 'bootstrap/cache/routes-v7.php'];
+ $values = [];
+ $env = $this->path('.env');
+ if (is_file($env)) {
+ foreach (file($env, FILE_IGNORE_NEW_LINES) as $line) {
+ if (preg_match('/^\s*(APP_(?:CONFIG|EVENTS|PACKAGES|SERVICES|ROUTES)_CACHE)\s*=\s*(.*?)\s*$/D', $line, $match)) {
+ $values[$match[1]] = trim($match[2], "\"'");
+ }
+ }
+ }
+ foreach ($expected as $key => $relative) {
+ $value = getenv($key);
+ if ($value === false) {
+ $value = $values[$key] ?? '';
+ }
+ if ($value !== '' && str_replace('\\', '/', $value) !== $relative && str_replace('\\', '/', $value) !== $this->host.'/'.$relative) {
+ throw new \RuntimeException('Cache Laravel personalizado exige plano de implantação próprio.');
+ }
+ }
+ }
+
+ private function locked(callable $work): mixed
+ {
+ $lock = $this->path('storage/app/private/module-deployments/deployment.lock');
+ if (! is_dir(dirname($lock))) {
+ mkdir(dirname($lock), 0700, true);
+ }
+ $stream = fopen($lock, 'c+');
+ if (! $stream || ! flock($stream, LOCK_EX | LOCK_NB)) {
+ throw new \RuntimeException('Outra implantação em curso.');
+ }
+ try {
+ return $work();
+ } finally {
+ flock($stream, LOCK_UN);
+ fclose($stream);
+ }
+ }
+
+ private function journal(string $id, array $data): void
+ {
+ self::write($this->path('storage/app/private/module-deployments/'.$id.'/journal.json'), json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+ }
+
+ private function caches(): array
+ {
+ $out = [];
+ foreach (glob($this->path('bootstrap/cache').'/*.php') ?: [] as $file) {
+ if (! in_array(basename($file), ['config.php', 'events.php', 'packages.php', 'services.php'], true) && ! preg_match('/^routes(-v[0-9]+)?\.php$/D', basename($file))) {
+ continue;
+ }
+ $relative = 'bootstrap/cache/'.basename($file);
+ $out[$relative] = hash_file('sha256', $this->path($relative));
+ }
+ ksort($out);
+
+ return $out;
+ }
+
+ public function rebuildCaches(string $id, string $operator, bool $stopped, ?callable $checkpoint = null): array
+ {
+ if (! preg_match('/^[a-f0-9]{24}$/D', $id)) {
+ throw new \RuntimeException('Implantação inválida.');
+ }
+
+ return $this->locked(function () use ($id, $operator, $stopped, $checkpoint) {
+ $this->offline($stopped);
+ if (trim($operator) === '') {
+ throw new \RuntimeException('Operador obrigatório.');
+ }
+ $prefix = 'storage/app/private/module-deployments/'.$id;
+ $data = json_decode(file_get_contents($this->path($prefix.'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
+ if ($data['state'] !== 'applied') {
+ throw new \RuntimeException('Código não está aplicado.');
+ }
+ $this->verifyApplied($data, $id);
+ $run = bin2hex(random_bytes(6));
+ $beforeCaches = $this->caches();
+ foreach ($beforeCaches as $file => $hash) {
+ self::write($this->path($prefix.'/cache-runs/'.$run.'/before/'.$file), file_get_contents($this->path($file)));
+ }
+ $data['cache_state'] = 'building';
+ $this->journal($id, $data);
+ if ($this->caches() !== $beforeCaches) {
+ throw new \RuntimeException('Cache mudou durante preparação da compilação.');
+ }
+ // Clear before booting Artisan: an interrupted config cache must never prevent recovery.
+ foreach ($beforeCaches as $file => $hash) {
+ if (! unlink($this->path($file))) {
+ throw new \RuntimeException('Falha ao invalidar cache antes da compilação.');
+ }
+ }
+ $failure = null;
+ $commands = [];
+ try {
+ foreach (['config:cache', 'route:cache', 'event:cache'] as $command) {
+ $log = $this->path($prefix.'/cache-runs/'.$run.'/native-cache.log');
+ if (! is_dir(dirname($log))) {
+ mkdir(dirname($log), 0700, true);
+ }
+ $process = proc_open([PHP_BINARY, $this->path('artisan'), $command], [0 => ['pipe', 'r'], 1 => ['file', $log, 'a'], 2 => ['file', $log, 'a']], $pipes, $this->host);
+ if (! is_resource($process)) {
+ throw new \RuntimeException('Não foi possível iniciar a compilação nativa.');
+ }
+ fclose($pipes[0]);
+ $exit = proc_close($process);
+ $commands[] = ['command' => $command, 'exit_code' => $exit];
+ if ($exit !== 0) {
+ throw new \RuntimeException('Falha na compilação nativa de cache; consultar log privado.');
+ }
+ $checkpoint && $checkpoint($command);
+ }
+ } catch (\Throwable $error) {
+ $failure = $error;
+ } finally {
+ $data['built_cache_files'] = $this->caches();
+ $data['cache_state'] = $failure ? 'failed' : 'built';
+ $data['cache_runs'][] = ['id' => $run, 'operator' => $operator, 'at' => gmdate('c'), 'commands' => $commands, 'state' => $data['cache_state']];
+ $this->journal($id, $data);
+ }
+ if ($failure) {
+ throw $failure;
+ }
+
+ return ['deployment_id' => $id, 'cache_state' => 'built', 'restart_required' => true, 'maintenance_preserved' => true];
+ });
+ }
+
+ public function apply(string $bundle, string $manifestHash, string $target, string $operator, bool $stopped, ?callable $checkpoint = null): array
+ {
+ return $this->locked(function () use ($bundle, $manifestHash, $target, $operator, $stopped, $checkpoint) {
+ $this->offline($stopped);
+ if (trim($operator) === '') {
+ throw new \RuntimeException('Operador nominativo obrigatório.');
+ }
+ [$bundle, $manifest] = $this->manifest($bundle, $manifestHash);
+ $id = $this->attempt($manifestHash);
+ $journalPath = $this->path('storage/app/private/module-deployments/'.$id.'/journal.json');
+ if (is_file($journalPath)) {
+ $old = json_decode(file_get_contents($journalPath), true, 32, JSON_THROW_ON_ERROR);
+ if ($old['state'] !== 'applied') {
+ throw new \RuntimeException('Implantação anterior exige recuperação explícita.');
+ }
+ $this->verifyApplied($old, $id);
+
+ return ['deployment_id' => $id, 'state' => 'applied', 'replayed' => true, 'restart_required' => true];
+ }
+ $plan = $this->plan($bundle, $manifestHash);
+ if (! hash_equals($target, $plan['target_sha256'])) {
+ throw new \RuntimeException('Destino de código divergente.');
+ }
+ $package = $this->path('packages/internal-requisitions');
+ $prefix = 'storage/app/private/module-deployments/'.$id;
+ $data = ['state' => 'prepared', 'operator' => $operator, 'created_at' => gmdate('c'), 'manifest_sha256' => $manifestHash,
+ 'host_identity' => hash('sha256', $this->host),
+ 'previous' => $plan['previous'],
+ 'pointer_sha256' => $plan['pointer_sha256'], 'files' => [], 'cache_files' => [], 'bundle_files' => $manifest['support_files']];
+ foreach ($manifest['host_overlays'] as $file => $hashes) {
+ self::write($this->path($prefix.'/before/'.$file), file_get_contents($this->path($file)));
+ $hashes['before_sha256'] = $plan['host_files'][$file];
+ $data['files'][$file] = $hashes;
+ }
+ foreach (glob($this->path('bootstrap/cache').'/*.php') ?: [] as $cache) {
+ if (! in_array(basename($cache), ['config.php', 'events.php', 'packages.php', 'services.php'], true) && ! preg_match('/^routes(-v[0-9]+)?\.php$/D', basename($cache))) {
+ continue;
+ }
+ $relative = 'bootstrap/cache/'.basename($cache);
+ $this->path($relative);
+ self::write($this->path($prefix.'/before/'.$relative), file_get_contents($cache));
+ $data['cache_files'][$relative] = hash_file('sha256', $cache);
+ }
+ foreach ($manifest['support_files'] as $file => $hash) {
+ self::write($this->path($prefix.'/staged/'.$file), file_get_contents($bundle.'/'.$file));
+ }
+ self::write($this->path($prefix.'/staged/manifest.json'), file_get_contents($bundle.'/manifest.json'));
+ $this->journal($id, $data);
+ $checkpoint && $checkpoint('prepared');
+ $data['state'] = 'applying';
+ $this->journal($id, $data);
+ foreach ($data['cache_files'] as $file => $hash) {
+ if (! hash_equals($hash, hash_file('sha256', $this->path($file)))) {
+ throw new \RuntimeException('Cache mudou durante implantação.');
+ }
+ if (! unlink($this->path($file))) {
+ throw new \RuntimeException('Não foi possível invalidar cache.');
+ }
+ }
+ $checkpoint && $checkpoint('caches-cleared');
+ foreach ($data['files'] as $file => $hashes) {
+ if (! hash_equals($hashes['before_sha256'], hash_file('sha256', $this->path($file)))) {
+ throw new \RuntimeException('Ficheiro mudou durante implantação.');
+ }
+ self::write($this->path($file), file_get_contents($bundle.'/'.$file));
+ $checkpoint && $checkpoint('file:'.$file);
+ }
+ // Both resolved paths are checked within the host before this directory move.
+ if ($data['previous'] !== null) {
+ if (! rename($package, $this->path($prefix.'/previous-bundle'))) {
+ throw new \RuntimeException('Falha ao preservar bundle anterior.');
+ }
+ $checkpoint && $checkpoint('previous-bundle-preserved');
+ }
+ if (! rename($this->path($prefix.'/staged'), $package)) {
+ throw new \RuntimeException('Falha na troca do bundle.');
+ }
+ $checkpoint && $checkpoint('bundle-switched');
+ $this->verifyApplied($data, $id, false);
+ self::write($this->path('storage/app/private/module-deployments/current.json'), json_encode(['deployment_id' => $id, 'manifest_sha256' => $manifestHash], JSON_THROW_ON_ERROR));
+ $data['state'] = 'applied';
+ $data['applied_at'] = gmdate('c');
+ $this->journal($id, $data);
+
+ return ['deployment_id' => $id, 'state' => 'applied', 'restart_required' => true, 'maintenance_preserved' => true, 'configuration_changed' => false, 'activation_performed' => false];
+ });
+ }
+
+ private function verifyApplied(array $data, string $id, bool $checkCurrent = true): void
+ {
+ if ($checkCurrent && $this->current() !== ['deployment_id' => $id, 'manifest_sha256' => $data['manifest_sha256']]) {
+ throw new \RuntimeException('A implantação já não é a versão activa.');
+ }
+ if (! hash_equals($data['host_identity'], hash('sha256', $this->host))) {
+ throw new \RuntimeException('Registo pertence a outro host.');
+ }
+ if (! hash_equals($data['pointer_sha256'], hash_file('sha256', $this->path('packages/purchasing/current.json')))) {
+ throw new \RuntimeException('Ponteiro de Compras mudou.');
+ }
+ $this->manifest($this->path('packages/internal-requisitions'), $data['manifest_sha256']);
+ foreach ($data['files'] as $file => $hashes) {
+ if (! hash_equals($hashes['after_sha256'], hash_file('sha256', $this->path($file)))) {
+ throw new \RuntimeException('Código aplicado alterado.');
+ }
+ }
+ foreach ($data['bundle_files'] as $file => $hash) {
+ if (! hash_equals($hash, hash_file('sha256', $this->path('packages/internal-requisitions/'.$file)))) {
+ throw new \RuntimeException('Bundle aplicado alterado.');
+ }
+ }
+ }
+
+ public function rollback(string $id, string $operator, bool $stopped): array
+ {
+ if (! preg_match('/^[a-f0-9]{24}$/D', $id)) {
+ throw new \RuntimeException('Implantação inválida.');
+ }
+
+ return $this->locked(function () use ($id, $operator, $stopped) {
+ $this->offline($stopped);
+ if (trim($operator) === '') {
+ throw new \RuntimeException('Operador obrigatório.');
+ }
+ $prefix = 'storage/app/private/module-deployments/'.$id;
+ $data = json_decode(file_get_contents($this->path($prefix.'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
+ $active = $this->current();
+ $own = ['deployment_id' => $id, 'manifest_sha256' => $data['manifest_sha256']];
+ if ($active !== $own && $active !== ($data['previous'] ?? null)) {
+ throw new \RuntimeException('Outra versão está activa; recusar reversão fora de ordem.');
+ }
+ if (! hash_equals($data['host_identity'], hash('sha256', $this->host))) {
+ throw new \RuntimeException('Registo pertence a outro host.');
+ }
+ if (! hash_equals($data['pointer_sha256'], hash_file('sha256', $this->path('packages/purchasing/current.json')))) {
+ throw new \RuntimeException('Ponteiro divergiu; não recuperar por arrasto.');
+ }
+ if ($data['state'] === 'rolled-back') {
+ return ['deployment_id' => $id, 'state' => 'rolled-back', 'replayed' => true, 'restart_required' => true, 'maintenance_preserved' => true, 'data_schema_changed' => false];
+ }
+ $rebuilt = array_key_exists('built_cache_files', $data);
+ if ($rebuilt && $this->caches() !== $data['built_cache_files']) {
+ throw new \RuntimeException('Cache mudou após compilação registada.');
+ }
+ foreach ($data['files'] as $file => $hashes) {
+ if (! in_array(hash_file('sha256', $this->path($file)), [$hashes['before_sha256'], $hashes['after_sha256']], true)) {
+ throw new \RuntimeException('Código local alterado; recuperação recusada.');
+ }
+ if (! hash_equals($hashes['before_sha256'], hash_file('sha256', $this->path($prefix.'/before/'.$file)))) {
+ throw new \RuntimeException('Backup de código divergente.');
+ }
+ }
+ foreach ($data['cache_files'] as $file => $hash) {
+ if (! hash_equals($hash, hash_file('sha256', $this->path($prefix.'/before/'.$file)))) {
+ throw new \RuntimeException('Backup de cache divergente.');
+ }
+ if (! $rebuilt && is_file($this->path($file)) && ! hash_equals($hash, hash_file('sha256', $this->path($file)))) {
+ throw new \RuntimeException('Cache recriado; reconciliar antes de recuperar.');
+ }
+ }
+ if (! $rebuilt && array_diff(array_keys($this->caches()), array_keys($data['cache_files'])) !== []) {
+ throw new \RuntimeException('Cache recriado; reconciliar antes de recuperar.');
+ }
+ if (is_dir($this->path($prefix.'/previous-bundle'))) {
+ $this->manifest($this->path($prefix.'/previous-bundle'), $data['previous']['manifest_sha256']);
+ }
+ if (is_dir($this->path('packages/internal-requisitions'))) {
+ $isPrevious = $active === ($data['previous'] ?? null) && $data['previous'] !== null && ! is_dir($this->path($prefix.'/previous-bundle'));
+ if ($isPrevious) {
+ $this->manifest($this->path('packages/internal-requisitions'), $data['previous']['manifest_sha256']);
+ }
+ if (! $isPrevious) {
+ $this->manifest($this->path('packages/internal-requisitions'), $data['manifest_sha256']);
+ foreach ($data['bundle_files'] as $file => $hash) {
+ if (! hash_equals($hash, hash_file('sha256', $this->path('packages/internal-requisitions/'.$file)))) {
+ throw new \RuntimeException('Bundle alterado; recuperação recusada.');
+ }
+ }
+ if (! rename($this->path('packages/internal-requisitions'), $this->path($prefix.'/withdrawn'))) {
+ throw new \RuntimeException('Falha ao retirar bundle.');
+ }
+ }
+ }
+ if (is_dir($this->path($prefix.'/previous-bundle'))) {
+ $this->manifest($this->path($prefix.'/previous-bundle'), $data['previous']['manifest_sha256']);
+ if (! rename($this->path($prefix.'/previous-bundle'), $this->path('packages/internal-requisitions'))) {
+ throw new \RuntimeException('Falha ao repor bundle anterior.');
+ }
+ }
+ $data['state'] = 'rolling-back';
+ $this->journal($id, $data);
+ foreach ($data['files'] as $file => $hashes) {
+ self::write($this->path($file), file_get_contents($this->path($prefix.'/before/'.$file)));
+ }
+ if ($rebuilt) {
+ foreach ($data['built_cache_files'] as $file => $hash) {
+ self::write($this->path($prefix.'/rollback-cache/'.$file), file_get_contents($this->path($file)));
+ unlink($this->path($file));
+ }
+ $data['cache_rollback'] = 'invalidated-rebuilt';
+ } else {
+ foreach ($data['cache_files'] as $file => $hash) {
+ self::write($this->path($file), file_get_contents($this->path($prefix.'/before/'.$file)));
+ }
+ }
+ $currentPath = $this->path('storage/app/private/module-deployments/current.json');
+ if ($data['previous'] !== null) {
+ self::write($currentPath, json_encode($data['previous'], JSON_THROW_ON_ERROR));
+ } elseif (is_file($currentPath)) {
+ unlink($currentPath);
+ }
+ $data['state'] = 'rolled-back';
+ $data['rollback_operator'] = $operator;
+ $data['rolled_back_at'] = gmdate('c');
+ $this->journal($id, $data);
+
+ return ['deployment_id' => $id, 'state' => 'rolled-back', 'restart_required' => true, 'maintenance_preserved' => true, 'data_schema_changed' => false];
+ });
+ }
+}
diff --git a/integrations/sgf/operational/Composition.php b/integrations/sgf/operational/Composition.php
index 506fb37..bd40443 100644
--- a/integrations/sgf/operational/Composition.php
+++ b/integrations/sgf/operational/Composition.php
@@ -14,6 +14,7 @@ final class Composition
}
require $bundle.'/integrations/sgf/internal-requisitions/routes.php';
require $bundle.'/integrations/sgf/access/routes.php';
+ require $bundle.'/integrations/sgf/operational/setup-routes.php';
foreach (Route::getRoutes() as $route) {
if (! isset($previous[spl_object_id($route)])) {
$route->middleware(RuntimeContext::class);
diff --git a/integrations/sgf/operational/Installation.php b/integrations/sgf/operational/Installation.php
index 78a39b7..f87ac56 100644
--- a/integrations/sgf/operational/Installation.php
+++ b/integrations/sgf/operational/Installation.php
@@ -25,7 +25,7 @@ final class Installation
public static function migrationHash(): string
{
- return hash('sha256', file_get_contents(__DIR__.'/BudgetSchema.php').file_get_contents(__DIR__.'/budget-contract.json').file_get_contents(dirname(__DIR__).'/internal-requisitions/Schema.php'));
+ return hash('sha256', file_get_contents(__DIR__.'/BudgetSchema.php').file_get_contents(__DIR__.'/BudgetUiSchema.php').file_get_contents(__DIR__.'/budget-contract.json').file_get_contents(dirname(__DIR__).'/internal-requisitions/Schema.php'));
}
public static function signature(ConnectionInterface $db, array $tables): string
@@ -42,7 +42,7 @@ final class Installation
public static function tables(): array
{
- return [...self::TABLES, 'permission_grants', 'ir_requests', 'ir_steps', 'ir_events', 'ir_commands', 'ir_policies', 'ir_policy_versions'];
+ return [...self::TABLES, ...BudgetUiSchema::TABLES, 'permission_grants', 'ir_requests', 'ir_steps', 'ir_events', 'ir_commands', 'ir_policies', 'ir_policy_versions'];
}
public static function migrate(ConnectionInterface $db, string $expectedTarget, string $backupHash, string $operator): array
@@ -56,7 +56,7 @@ final class Installation
$schema = $db->getSchemaBuilder();
if ($schema->hasTable('sgc_installations')) {
$old = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first();
- if (! $old || $old->revision !== 1 || ! hash_equals($old->migration_sha256, self::migrationHash()) || ! hash_equals($old->schema_signature, self::signature($db, self::tables()))) {
+ if (! $old || $old->revision !== 2 || ! hash_equals($old->migration_sha256, self::migrationHash()) || ! hash_equals($old->schema_signature, self::signature($db, self::tables()))) {
throw new \RuntimeException('Instalação/schema divergente; reconciliar sem sobrescrever.');
}
@@ -89,6 +89,7 @@ final class Installation
$db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$parts[1]}) OR (unit_id IS NOT NULL AND permission::text IN ($literals)))");
}
RequisitionSchema::install($db);
+ BudgetUiSchema::create($db);
$schema->create('sgc_installations', function (Blueprint $table) {
$table->string('module')->primary();
$table->integer('revision');
@@ -99,7 +100,7 @@ final class Installation
$table->string('database_operator');
$table->timestampTz('prepared_at');
});
- $db->table('sgc_installations')->insert(['module' => 'internal-requisitions', 'revision' => 1, 'schema_signature' => self::signature($db, self::tables()), 'migration_sha256' => self::migrationHash(), 'backup_sha256' => $backupHash, 'operator' => $operator, 'database_operator' => $db->selectOne('select current_user as name')->name, 'prepared_at' => now()]);
+ $db->table('sgc_installations')->insert(['module' => 'internal-requisitions', 'revision' => 2, 'schema_signature' => self::signature($db, self::tables()), 'migration_sha256' => self::migrationHash(), 'backup_sha256' => $backupHash, 'operator' => $operator, 'database_operator' => $db->selectOne('select current_user as name')->name, 'prepared_at' => now()]);
$db->unprepared("CREATE FUNCTION sgc_installations_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve installation history'; END; \$\$; CREATE TRIGGER sgc_installations_immutable BEFORE UPDATE OR DELETE ON sgc_installations FOR EACH ROW EXECUTE FUNCTION sgc_installations_immutable();");
return ['prepared' => true, 'replayed' => false, 'activated' => false];
@@ -115,6 +116,10 @@ final class Installation
abort_unless($scope['unit_id'] !== null && in_array(implode(':', $scope), config('purchasing-operational.scopes', []), true), 403, 'Opção interna não configurada para esta unidade.');
abort_unless($db->getSchemaBuilder()->hasTable('sgc_installations'), 503, 'Schema operacional não preparado.');
$row = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first();
- abort_unless($row && $row->revision === 1 && hash_equals($row->migration_sha256, self::migrationHash()) && hash_equals($row->schema_signature, self::signature($db, self::tables())), 503, 'Schema operacional não preparado.');
+ $signatureMatches=$row && hash_equals($row->schema_signature, self::signature($db, self::tables()));
+ if(!$signatureMatches && class_exists(\Tempo\Purchasing\SgfSourcing\Schema::class)){
+ $signatureMatches=\Tempo\Purchasing\SgfSourcing\Schema::compatible($db);
+ }
+ abort_unless($row && $row->revision === 2 && hash_equals($row->migration_sha256, self::migrationHash()) && $signatureMatches, 503, 'Schema operacional não preparado.');
}
}
diff --git a/integrations/sgf/operational/SqlPrivileges.php b/integrations/sgf/operational/SqlPrivileges.php
new file mode 100644
index 0000000..106fea0
--- /dev/null
+++ b/integrations/sgf/operational/SqlPrivileges.php
@@ -0,0 +1,41 @@
+ 'SELECT, INSERT', 'cb_origins' => 'SELECT, INSERT', 'cb_budgets' => 'SELECT, INSERT, UPDATE',
+ 'cb_budget_entries' => 'SELECT, INSERT', 'cb_audit' => 'SELECT, INSERT', 'cb_ui_commands' => 'SELECT, INSERT',
+ 'cb_budget_consumptions' => 'SELECT', 'cb_budget_reinforcements' => 'SELECT', 'cb_consumption_sources' => 'SELECT',
+ 'ir_requests' => 'SELECT, INSERT, UPDATE', 'ir_steps' => 'SELECT, INSERT, UPDATE', 'ir_policies' => 'SELECT, INSERT, UPDATE',
+ 'ir_events' => 'SELECT, INSERT', 'ir_commands' => 'SELECT, INSERT', 'ir_policy_versions' => 'SELECT, INSERT', 'sgc_installations' => 'SELECT',
+ ];
+
+ public static function plan(string $runtime): array
+ {
+ if (! preg_match('/^[a-z][a-z0-9_]{0,62}$/D', $runtime)) {
+ throw new \RuntimeException('Role runtime explícita inválida.');
+ }
+ $sql = [];
+ foreach (self::TABLES as $table => $rights) {
+ // New objects can inherit wider ACLs from the destination owner's
+ // defaults. Scope the correction to these new objects only; never
+ // change existing tables or ALTER DEFAULT PRIVILEGES globally.
+ $sql[] = 'REVOKE ALL PRIVILEGES ON TABLE public."'.$table.'" FROM "'.$runtime.'"';
+ $sql[] = 'REVOKE ALL PRIVILEGES ON TABLE public."'.$table.'" FROM PUBLIC';
+ $sql[] = 'GRANT '.$rights.' ON TABLE public."'.$table.'" TO "'.$runtime.'"';
+ if (str_contains($rights, 'INSERT')) {
+ $sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM "'.$runtime.'"';
+ $sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM PUBLIC';
+ $sql[] = 'GRANT USAGE ON SEQUENCE public."'.$table.'_id_seq" TO "'.$runtime.'"';
+ } elseif ($table !== 'sgc_installations') {
+ $sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM "'.$runtime.'"';
+ $sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM PUBLIC';
+ }
+ }
+
+ return ['runtime_role' => $runtime, 'confirmation_required' => true, 'automatically_applied' => false, 'statements' => $sql, 'excluded' => ['CREATE', 'ownership', 'DELETE', 'UPDATE on append-only history', 'all-tables/default-privileges grants']];
+ }
+}
diff --git a/integrations/sgf/operational/bootstrap.php b/integrations/sgf/operational/bootstrap.php
index fa2e4ce..35b8564 100644
--- a/integrations/sgf/operational/bootstrap.php
+++ b/integrations/sgf/operational/bootstrap.php
@@ -2,6 +2,8 @@
// Explicit deployment composition; never required by the installed SGF provider.
require_once __DIR__.'/BudgetSchema.php';
+require_once __DIR__.'/BudgetUiSchema.php';
+require_once __DIR__.'/SqlPrivileges.php';
require_once dirname(__DIR__).'/access/AccessOverlay.php';
require_once __DIR__.'/Installation.php';
require_once dirname(__DIR__).'/internal-requisitions/bootstrap.php';
diff --git a/integrations/sgf/operational/budget-setup.php b/integrations/sgf/operational/budget-setup.php
new file mode 100644
index 0000000..c8b4a54
--- /dev/null
+++ b/integrations/sgf/operational/budget-setup.php
@@ -0,0 +1,20 @@
+Preparação orçamental · Compras internas
+Preparação orçamental · Compras internas Empresa = (int) $scope['company_id'] ?> · Unidade = (int) $scope['unit_id'] ?>
+Requisições e parametrização · Navegação SGF
+Cadastre apenas centros autorizados. Seleccione os centros já existentes; novas dotações exigem aprovação por outra identidade. Nenhum valor está predefinido.
+Novo centro Código Fundamento Cadastrar centro
+Preparar dotação Centro Exercício Natureza Funcionamento Investimento Montante MZN Inclui impostoFundamento Guardar dotação para aprovação
+Dotações da unidade
+
+
diff --git a/integrations/sgf/operational/entry.php b/integrations/sgf/operational/entry.php
index 593f33a..1fd2d72 100644
--- a/integrations/sgf/operational/entry.php
+++ b/integrations/sgf/operational/entry.php
@@ -4,3 +4,4 @@
require_once __DIR__.'/integrations/sgf/operational/bootstrap.php';
require_once __DIR__.'/integrations/sgf/operational/RuntimeContext.php';
require_once __DIR__.'/integrations/sgf/operational/Composition.php';
+require_once __DIR__.'/integrations/sgf/sourcing/bootstrap.php';
diff --git a/integrations/sgf/operational/frontend-routes.php b/integrations/sgf/operational/frontend-routes.php
new file mode 100644
index 0000000..3766c65
--- /dev/null
+++ b/integrations/sgf/operational/frontend-routes.php
@@ -0,0 +1,13 @@
+file($file, ['Content-Type' => str_ends_with($asset, '.js') ? 'application/javascript' : 'text/css', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable']);
+})->where('asset', '[A-Za-z0-9_-]+\.(js|css)');
diff --git a/integrations/sgf/operational/routes.php b/integrations/sgf/operational/routes.php
index 5f505a5..a47e3a0 100644
--- a/integrations/sgf/operational/routes.php
+++ b/integrations/sgf/operational/routes.php
@@ -2,4 +2,6 @@
use Tempo\Purchasing\Delivery\Composition;
+require __DIR__.'/frontend-routes.php';
Composition::routes(dirname(__DIR__, 3));
+if(config('purchasing-sourcing.enabled')===true)require dirname(__DIR__).'/sourcing/routes.php';
diff --git a/integrations/sgf/operational/setup-routes.php b/integrations/sgf/operational/setup-routes.php
new file mode 100644
index 0000000..1f74466
--- /dev/null
+++ b/integrations/sgf/operational/setup-routes.php
@@ -0,0 +1,12 @@
+group(function () {
+ Route::get('/purchasing/internal-requisitions/budget-setup', [BudgetSetup::class, 'page']);
+ Route::post('/api/internal-requisitions/centres', [BudgetSetup::class, 'centre']);
+});
diff --git a/integrations/sgf/sourcing/Controller.php b/integrations/sgf/sourcing/Controller.php
new file mode 100644
index 0000000..a6cc74d
--- /dev/null
+++ b/integrations/sgf/sourcing/Controller.php
@@ -0,0 +1,19 @@
+json((new Workspace($request->user()))->read($id))->header('Cache-Control','private, no-store');}
+ public function command(Request $request,int $id,string $action){
+ try{return response()->json((new Workspace($request->user()))->command($id,$action,$request->all()));}
+ catch(RequisitionFailure $e){return response()->json(['message'=>$e->getMessage()],$e->status);}
+ catch(\InvalidArgumentException $e){return response()->json(['message'=>$e->getMessage()],422);}
+ }
+ public function page(Request $request,int $id){
+ (new Workspace($request->user()))->read($id);
+ $contract=['id'=>$id,'csrf'=>csrf_token(),'context'=>$request->session()->get('company_context.token')];
+ ob_start();require __DIR__.'/screen.php';return response(ob_get_clean())->header('Cache-Control','private, no-store');
+ }
+ public function script(){return response(file_get_contents(__DIR__.'/screen.mjs'))->header('Content-Type','text/javascript');}
+}
diff --git a/integrations/sgf/sourcing/Schema.php b/integrations/sgf/sourcing/Schema.php
new file mode 100644
index 0000000..9dc319f
--- /dev/null
+++ b/integrations/sgf/sourcing/Schema.php
@@ -0,0 +1,71 @@
+table('sgc_installations')->where('module','sourcing')->first();
+ return $row&&$row->revision===1&&hash_equals($row->migration_sha256,self::hash())&&hash_equals($row->schema_signature,self::signature($db));
+ }
+ public static function migrate(ConnectionInterface $db,string $backupHash,string $operator):array{
+ if($db->getDriverName()!=='pgsql'||!preg_match('/^[a-f0-9]{64}$/D',$backupHash)||trim($operator)==='')throw new \RuntimeException('Backup e operador obrigatórios.');
+ return $db->transaction(function()use($db,$backupHash,$operator){
+ $db->select("select pg_advisory_xact_lock(hashtext('tempo.sourcing.install.v1'))");
+ if($db->table('sgc_installations')->where('module','sourcing')->exists()){
+ if(!self::compatible($db))throw new \RuntimeException('Schema de sourcing divergente.');
+ return ['prepared'=>true,'replayed'=>true];
+ }
+ $old=$db->table('sgc_installations')->where('module','internal-requisitions')->first();
+ if(!$old||$old->revision!==2||!hash_equals($old->schema_signature,Installation::signature($db,Installation::tables())))throw new \RuntimeException('Conferir schema anterior antes de ampliar catálogo.');
+ $schema=$db->getSchemaBuilder();
+ foreach(self::TABLES as $table){
+ if($schema->hasTable($table))throw new \RuntimeException('Schema parcial: não sobrescrever.');
+ $schema->create($table,function(Blueprint $t)use($table){
+ $t->id();foreach(['tenant_id','company_id','unit_id'] as $key)$t->unsignedBigInteger($key);
+ if($table==='ss_suppliers'){$t->string('reference');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','reference']);}
+ elseif($table==='ss_processes'){$t->unsignedBigInteger('request_id');$t->unsignedInteger('version');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','request_id']);}
+ elseif($table==='ss_events'){$t->unsignedBigInteger('request_id');$t->unsignedInteger('version');$t->unsignedBigInteger('actor_id');$t->string('action');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','request_id','version']);}
+ else{$t->unsignedBigInteger('actor_id');$t->uuid('operation_key');$t->string('request_hash',64);$t->jsonb('result');$t->unique(['tenant_id','company_id','unit_id','actor_id','operation_key']);}
+ $t->timestampTz('created_at');
+ });
+ $db->statement("ALTER TABLE $table ADD FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)");
+ $db->statement("ALTER TABLE $table ADD UNIQUE(tenant_id,company_id,unit_id,id)");
+ }
+ foreach(['ss_processes','ss_events'] as $table)$db->statement("ALTER TABLE $table ADD FOREIGN KEY(tenant_id,company_id,unit_id,request_id) REFERENCES ir_requests(tenant_id,company_id,unit_id,id)");
+ foreach(['ss_events','ss_commands'] as $table){
+ $db->statement("ALTER TABLE $table ADD FOREIGN KEY(actor_id) REFERENCES users(id)");
+ $db->unprepared("CREATE FUNCTION {$table}_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve sourcing history'; END; \$\$; CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON $table FOR EACH ROW EXECUTE FUNCTION {$table}_immutable();");
+ }
+ $db->statement("ALTER TABLE ss_processes ADD CHECK(payload->'confirmed' IS NULL OR payload->'confirmed'='null'::jsonb OR (payload->'confirmed'->>'confirmed_by')::bigint<>(payload->'confirmed'->'proposal'->>'proposed_by')::bigint)");
+ $db->unprepared(<<<'SQL'
+CREATE FUNCTION ss_process_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE requester bigint;
+BEGIN
+ IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Preserve sourcing origin'; END IF;
+ IF (NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.request_id) IS DISTINCT FROM (OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.request_id) OR NEW.payload->'origin' IS DISTINCT FROM OLD.payload->'origin' THEN RAISE EXCEPTION 'Immutable sourcing origin'; END IF;
+ IF NEW.version<>OLD.version+1 THEN RAISE EXCEPTION 'Sourcing version must advance once'; END IF;
+ IF OLD.payload->'confirmed' IS NOT NULL AND OLD.payload->'confirmed'<>'null'::jsonb THEN RAISE EXCEPTION 'Confirmed award immutable'; END IF;
+ SELECT requester_id INTO requester FROM ir_requests WHERE id=NEW.request_id;
+ IF NEW.payload->'confirmed' IS NOT NULL AND NEW.payload->'confirmed'<>'null'::jsonb AND (requester=(NEW.payload->'confirmed'->>'confirmed_by')::bigint OR requester=(NEW.payload->'confirmed'->'proposal'->>'proposed_by')::bigint) THEN RAISE EXCEPTION 'Requester cannot award'; END IF;
+ RETURN NEW;
+END; $$;
+CREATE TRIGGER ss_process_guard BEFORE UPDATE OR DELETE ON ss_processes FOR EACH ROW EXECUTE FUNCTION ss_process_guard();
+SQL);
+ $c=$db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
+ if(!preg_match('/^CHECK\s*\((.*)\)$/s',$c->definition,$match))throw new \RuntimeException('Catálogo desconhecido.');
+ $rights=implode(',',array_map(fn($r)=>$db->getPdo()->quote($r).'::text',self::RIGHTS));
+ $db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
+ $db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$match[1]}) OR (unit_id IS NOT NULL AND permission::text IN ($rights)))");
+ $db->table('sgc_installations')->insert(['module'=>'sourcing','revision'=>1,'schema_signature'=>self::signature($db),'migration_sha256'=>self::hash(),'backup_sha256'=>$backupHash,'operator'=>$operator,'database_operator'=>$db->selectOne('select current_user as role')->role,'prepared_at'=>now()]);
+ return ['prepared'=>true,'replayed'=>false,'tables'=>self::TABLES,'access_granted'=>false];
+ });
+ }
+}
diff --git a/integrations/sgf/sourcing/Workspace.php b/integrations/sgf/sourcing/Workspace.php
new file mode 100644
index 0000000..d431d32
--- /dev/null
+++ b/integrations/sgf/sourcing/Workspace.php
@@ -0,0 +1,100 @@
+port=new Port($actor);}
+ private function q(string $table){return DB::table('ss_'.$table)->where($this->port->scope());}
+ private function auth(string $action):void{
+ $this->port->authorize('view',null);
+ if($action==='view')return;
+ $right=match($action){'select','quote'=>'prepare','evaluate'=>'evaluate','propose'=>'propose','confirm'=>'confirm',default=>abort(404)};
+ $s=$this->port->scope();app(Permissions::class)->require($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);
+ }
+ private function policy(array $state=[]):array{return $state['policy']??json_decode(file_get_contents(__DIR__.'/comparison-defaults.json'),true,32,JSON_THROW_ON_ERROR);}
+ private function state(int $id):array{
+ $row=$this->q('processes')->where('request_id',$id)->lockForUpdate()->first();
+ $state=$row?json_decode($row->payload,true,64,JSON_THROW_ON_ERROR):['version'=>0,'suppliers'=>[],'quotes'=>[],'proposals'=>[],'confirmed'=>null];
+ $s=$this->port->scope();
+ foreach($state['quotes'] as $key=>&$quote){
+ $supplier=$this->q('suppliers')->where('id',$key)->first();
+ $payload=$supplier?json_decode($supplier->payload,true):[];
+ $quote['supplier_eligible']=($payload['active']??false)&&!($payload['blocked']??true)&&($payload['review_until']??'')>=date('Y-m-d');
+ foreach($quote['evaluations'] as &$evaluation){
+ $id=$evaluation['evaluator_id'];
+ $evaluation['eligible']=DB::table('company_memberships')->where(['user_id'=>$id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists()
+ &&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'unit.view',$s['unit_id'])
+ &&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.evaluate',$s['unit_id']);
+ }unset($evaluation);
+ }unset($quote);
+ return $state;
+ }
+ public function read(int $id):array{return $this->port->atomic(function()use($id){
+ abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
+ $this->auth('view');$r=$this->port->load($id);$this->port->authorize('view',$r);$s=$this->state($id);
+ return ['request'=>$r,'state'=>$s,'policy'=>$this->policy($s),'comparison'=>(new Sourcing)->comparison($s,$this->policy($s),date('Y-m-d')),'suppliers'=>$this->q('suppliers')->orderBy('id')->get(),'history'=>$this->q('events')->where('request_id',$id)->orderBy('version')->get(),'rights'=>$this->rights()];
+ });}
+ private function rights():array{
+ $s=$this->port->scope();$result=[];foreach(['prepare','evaluate','propose','confirm'] as $right)$result[$right]=app(Permissions::class)->has($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);return $result;
+ }
+ public function command(int $id,string $action,array $input):array{return $this->port->atomic(function()use($id,$action,$input){
+ abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
+ $this->auth($action);$r=$this->port->load($id);$this->port->authorize('view',$r);
+ $meta=Validator::make($input,['operation_key'=>'required|uuid','version'=>'required|integer|min:0'])->validate();
+ $meta['version']=(int)$meta['version'];
+ $hash=hash('sha256',json_encode([$id,$action,$input],JSON_THROW_ON_ERROR));
+ $old=$this->q('commands')->where('actor_id',$this->actor->id)->where('operation_key',$meta['operation_key'])->first();
+ if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com outros dados.');return json_decode($old->result,true);}
+ $state=$this->state($id);abort_unless($state['version']===$meta['version'],409,'Versão do processo alterada.');
+ $origin=['request_id'=>$r['id'],'origin_key'=>$r['origin_key'],'request_version'=>$r['version'],'budget_origin_id'=>$r['budget_origin_id'],'snapshot_hash'=>hash('sha256',json_encode([$r['data'],$r['approval_snapshot'],$r['reservation']],JSON_THROW_ON_ERROR))];
+ if($state['version']===0){$state['origin']=$origin;$state['policy']=$this->policy();}else abort_unless(($state['origin']??null)==$origin,409,'A origem mudou; reconcilie o processo sem criar outra reserva.');
+ $rules=match($action){
+ 'select'=>['supplier_id'=>'required|integer|min:1'],
+ 'quote'=>['supplier_id'=>'required|integer|min:1','reference'=>'required|string|max:100','currency'=>'required|in:MZN','valid_until'=>'required|date_format:Y-m-d','delivery_days'=>'required|integer|between:1,3650','payment_terms'=>'required|string|min:3|max:500','items'=>'required|array|min:1|max:100','items.*.line'=>'required|integer|min:0','items.*.unit_price'=>'required|string|regex:/^\d{1,10}(\.\d{1,2})?$/','items.*.discount_type'=>'sometimes|in:none,percent,amount','items.*.discount_value'=>'sometimes|string|regex:/^\d{1,10}(\.\d{1,2})?$/'],
+ 'evaluate'=>['supplier_id'=>'required|integer|min:1','quote_revision'=>'required|integer|min:1','quality'=>'required|numeric|between:0,10','history'=>'required|numeric|between:0,10','payment'=>'required|numeric|between:0,10','no_conflict'=>'accepted','reason'=>'required|string|min:10|max:2000'],
+ 'propose'=>['supplier_id'=>'required|integer|min:1','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'],
+ 'confirm'=>['proposal_index'=>'required|integer|min:0','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000'],default=>abort(404)};
+ $data=Validator::make($input,$rules)->validate();
+ foreach(['supplier_id','quote_revision','proposal_index','delivery_days'] as $field)if(isset($data[$field]))$data[$field]=(int)$data[$field];
+ if(isset($data['items']))foreach($data['items'] as &$line)$line['line']=(int)$line['line'];unset($line);
+ if($action==='select'){
+ $supplier=$this->q('suppliers')->where('id',$data['supplier_id'])->lockForUpdate()->first();abort_unless($supplier,404);
+ $payload=json_decode($supplier->payload,true);
+ $data['supplier']=['id'=>$supplier->id,'reference'=>$supplier->reference,'snapshot'=>$payload,'eligible'=>$payload['active']&&!$payload['blocked']&&$payload['review_until']>=date('Y-m-d')];
+ }
+ if(in_array($action,['propose','confirm'],true)){
+ $supplier=$action==='propose'?$data['supplier_id']:(end($state['proposals'])['supplier_id']??null);
+ $quote=$state['quotes'][$supplier]??null;abort_unless($quote,422,'Cotação em falta.');
+ // Do not invent an automatic budget revision or create another reserve.
+ $budget=DB::table('cb_budgets')->where($this->port->scope())->where('id',$r['reservation']['budget_id']??0)->lockForUpdate()->first();
+ $entries=DB::table('cb_budget_entries')->where($this->port->scope())->where('requisition_id',$r['budget_origin_id']);
+ $basis=$budget?($budget->includes_tax?$quote['total_minor']:$quote['subtotal_minor']):null;
+ abort_unless($budget&&$budget->approved_by&&$basis===(int)(clone $entries)->sum('reserved_minor')&&(int)(clone $entries)->sum('committed_minor')===0,409,'A alteração do valor exige adaptação orçamental ainda não integrada neste percurso. Nenhum movimento foi criado.');
+ $rules=$r['approval_snapshot'];$approvalAmount=$rules['approval_includes_tax']?$quote['total_minor']:$quote['subtotal_minor'];
+ abort_unless($approvalAmount<=$rules['first_limit_minor'],409,'A cadeia de adjudicação acima do primeiro limite ainda requer integração dos níveis existentes de Compras.');
+ if($action==='confirm'){
+ $members=$this->port->setup()['members'];
+ abort_unless(count(array_filter($members,fn($member)=>$member['user_id']===$this->actor->id&&$member['department_ref']===$r['department_ref']))===1,403,'Confirmação exige atribuição ao departamento da origem.');
+ }
+ }
+ $next=(new Sourcing)->transition($state,$r,$this->actor->id,$action,$data,$this->policy($state),date('Y-m-d'));
+ $next['version']=$state['version']+1;$now=now();$scope=$this->port->scope();
+ $values=['version'=>$next['version'],'payload'=>json_encode($next,JSON_THROW_ON_ERROR)];
+ if($state['version']===0)$this->q('processes')->insert($scope+['request_id'=>$id,...$values,'created_at'=>$now]);else $this->q('processes')->where('request_id',$id)->update($values);
+ $this->q('events')->insert($scope+['request_id'=>$id,'version'=>$next['version'],'actor_id'=>$this->actor->id,'action'=>$action,'payload'=>json_encode(['before_version'=>$state['version'],'input'=>$data,'state'=>$next],JSON_THROW_ON_ERROR),'created_at'=>$now]);
+ $failFile=storage_path('framework/sourcing-failure.fixture');clearstatcache(true,$failFile);
+ if(app()->environment('testing')&&preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',DB::connection()->getDatabaseName())&&is_file($failFile))throw new \RuntimeException('Injected sourcing persistence failure');
+ $result=['request_id'=>$id,'version'=>$next['version'],'confirmed'=>$next['confirmed']!==null];
+ $this->q('commands')->insert($scope+['actor_id'=>$this->actor->id,'operation_key'=>$meta['operation_key'],'request_hash'=>$hash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>$now]);
+ return $result;
+ });}
+}
diff --git a/integrations/sgf/sourcing/bootstrap.php b/integrations/sgf/sourcing/bootstrap.php
new file mode 100644
index 0000000..f2ec16e
--- /dev/null
+++ b/integrations/sgf/sourcing/bootstrap.php
@@ -0,0 +1,4 @@
+group(function(){
+ Route::get('/purchasing/sourcing/screen.mjs',[Controller::class,'script']);
+ Route::get('/purchasing/sourcing/{id}',[Controller::class,'page'])->whereNumber('id');
+ Route::get('/api/sourcing/{id}',[Controller::class,'show'])->whereNumber('id');
+ Route::post('/api/sourcing/{id}/{action}',[Controller::class,'command'])->whereNumber('id')->whereIn('action',['select','quote','evaluate','propose','confirm']);
+});
diff --git a/integrations/sgf/sourcing/screen.mjs b/integrations/sgf/sourcing/screen.mjs
new file mode 100644
index 0000000..d591df2
--- /dev/null
+++ b/integrations/sgf/sourcing/screen.mjs
@@ -0,0 +1,23 @@
+const c=JSON.parse(document.querySelector('#contract').textContent);let data;
+const el=id=>document.getElementById(id),money=v=>(v/100).toLocaleString('pt-MZ',{minimumFractionDigits:2,maximumFractionDigits:2})+' MZN';
+async function api(action,input){const r=await fetch('/api/sourcing/'+c.id+(action?'/'+action:''),{method:action?'POST':'GET',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':c.csrf,'X-SGF-Context':c.context,'X-Purchasing-Context':'procurement.internal'},...(action?{body:JSON.stringify({...input,version:data.state.version,operation_key:crypto.randomUUID()})}:{})});const d=await r.json();if(!r.ok)throw Error(d.message??JSON.stringify(d.errors));return d;}
+function options(select,rows){select.replaceChildren(...rows.map(([id,text])=>{const o=document.createElement('option');o.value=id;o.textContent=text;return o;}));}
+function textNode(tag,text){const node=document.createElement(tag);node.textContent=text;return node;}
+async function refresh(){data=await api();el('origin').textContent=`Requisição ${data.request.id}, versão ${data.request.version}, ${money(data.request.total_minor)}; processo versão ${data.state.version}. Mínimos: ${data.policy.minimum_proposals} fornecedores e ${data.policy.minimum_evaluators} avaliadores.`;
+ const table=document.createElement('table');table.append(textNode('caption',data.comparison.ready?'Comparação completa':'Comparação incompleta: recolher propostas e avaliações elegíveis'));const head=document.createElement('tr');for(const s of ['Fornecedor','Revisão','Valor','Entrega','Pagamento','Avaliações','Pontuação'])head.append(textNode('th',s));table.append(head);
+ for(const q of data.comparison.rows){const row=document.createElement('tr');for(const s of [data.state.suppliers[q.supplier_id]?.snapshot?.name??q.supplier_id,q.revision,money(q.total_minor),q.delivery_days,q.payment_terms,q.evaluation_count,data.comparison.scores[q.id]===undefined?'—':(data.comparison.scores[q.id]/100).toFixed(2)])row.append(textNode('td',String(s)));table.append(row);}el('comparison').replaceChildren(table);
+ const supplierRows=data.suppliers.map(s=>[s.id,JSON.parse(typeof s.payload==='string'?s.payload:JSON.stringify(s.payload)).name]);options(el('select').elements.supplier_id,supplierRows);
+ const selected=Object.entries(data.state.suppliers).map(([id,s])=>[id,s.snapshot.name]);options(el('quote').elements.supplier_id,selected);
+ const quotes=data.comparison.rows.map(q=>[q.supplier_id,`${data.state.suppliers[q.supplier_id].snapshot.name} · revisão ${q.revision}`]);options(el('evaluation').elements.supplier_id,quotes);options(el('proposal').elements.supplier_id,quotes);
+ el('lines').replaceChildren(...data.request.data.items.map((line,i)=>{const label=document.createElement('label');label.textContent=`${line.description} · ${line.quantity} · imposto ${line.tax_rate}% `;const input=document.createElement('input');input.name='price_'+i;input.required=true;input.inputMode='decimal';label.append(input);return label;}));
+ for(const right of ['prepare','evaluate','propose','confirm'])el(right).hidden=!data.rights[right]||data.state.confirmed!==null;
+ el('proposals').textContent=JSON.stringify(data.state.proposals,null,2);el('history').replaceChildren(...data.history.map(e=>textNode('p',`Versão ${e.version} · ${e.action} · identidade ${e.actor_id} · ${e.created_at}`)));
+ if(data.state.confirmed)el('message').textContent='Adjudicação confirmada. Processo fechado.';
+}
+async function submit(form,action,make){form.addEventListener('submit',async e=>{e.preventDefault();try{await api(action,make(new FormData(form)));el('message').textContent='Registado.';await refresh();}catch(error){el('message').textContent=error.message;}});}
+submit(el('select'),'select',f=>({supplier_id:Number(f.get('supplier_id'))}));
+submit(el('quote'),'quote',f=>({supplier_id:Number(f.get('supplier_id')),reference:f.get('reference'),currency:'MZN',valid_until:f.get('valid_until'),delivery_days:Number(f.get('delivery_days')),payment_terms:f.get('payment_terms'),items:data.request.data.items.map((_,i)=>({line:i,unit_price:f.get('price_'+i)}))}));
+submit(el('evaluation'),'evaluate',f=>({supplier_id:Number(f.get('supplier_id')),quote_revision:data.state.quotes[f.get('supplier_id')].revision,quality:Number(f.get('quality')),history:Number(f.get('history')),payment:Number(f.get('payment')),no_conflict:f.get('no_conflict')==='on',reason:f.get('reason')}));
+submit(el('proposal'),'propose',f=>({supplier_id:Number(f.get('supplier_id')),comparison_hash:data.comparison.fingerprint,reason:f.get('reason'),delivery_date:f.get('delivery_date'),delivery_location:f.get('delivery_location')}));
+submit(el('confirmation'),'confirm',f=>({proposal_index:data.state.proposals.length-1,comparison_hash:data.comparison.fingerprint,reason:f.get('reason')}));
+el('refresh').onclick=()=>refresh().catch(e=>el('message').textContent=e.message);refresh().catch(e=>el('message').textContent=e.message);
diff --git a/integrations/sgf/sourcing/screen.php b/integrations/sgf/sourcing/screen.php
new file mode 100644
index 0000000..8dcee25
--- /dev/null
+++ b/integrations/sgf/sourcing/screen.php
@@ -0,0 +1,11 @@
+Consulta de fornecedores e adjudicação
+
+Requisições Consulta de fornecedores e adjudicação Compras internas · contexto activo da empresa e unidade seleccionadas. Registo local de cotações; não envia pedidos a fornecedores.
Actualizar
+
+
+
+
+Confirmação independente Fundamento Confirmar a proposta apresentada
+Propostas de adjudicação preservadas Histórico por versão
+
diff --git a/packages/README.md b/packages/README.md
index 2a78044..18d8bed 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -7,3 +7,45 @@ O Compras carrega estes artefactos em todos os ambientes, incluindo produção,
`runtime-manifest.json` identifica cada ficheiro distribuído por SHA-256. Conferir com `composer run runtime:check`. Actualizações devem vir da origem comum, passar os testes dos adaptadores e renovar o manifesto; não editar os artefactos directamente nem aplicar formatação que altere os bytes aceites.
O empacotamento não executa migrações, activa pagamentos, cria direitos ou transporta dados. As funcionalidades sintéticas ainda presentes nos núcleos conservam o seu âmbito até à implementação da respectiva operação comercial. Não distribuir configurações privadas ou bases de avaliação com o produto.
+
+## Recepção de eventos para integração
+
+`integration/bootstrap.php` carrega um módulo independente de Laravel, com PHP/PDO, para consumir `sgc.warehouse.fulfilment-feed.v1`. Não integra os18artefactos financeiros fixados nem altera a instalação do SGF. `WarehouseFeed` valida o contrato; `WarehouseInbox` guarda os factos e o cursor atomicamente; `WarehousePull` percorre páginas com limites e retoma pelo ponto persistido.
+
+O host fornece uma ligação PDO SQLite/PostgreSQL em modo de excepções, a identificação estável da instalação de origem, empresa de origem, empresa local e geração do leitor. Estes vínculos devem vir da configuração autorizada do host, nunca de parâmetros livres recebidos do exterior. Os identificadores de empresa da origem e do destino são independentes. Diferentes leitores da mesma origem/empresa local partilham a deduplicação dos factos e conservam checkpoints separados. Uma geração nova de leitor permite reler uma origem após mudança de âmbito, preservando o checkpoint anterior; não representa outra conta de utilizador.
+
+A instalação usa `WarehouseInbox::schema()` como entrada explícita para a migração do host. O construtor e o processamento não criam tabelas. Instalação, privilégios mínimos, backup, configuração de acesso e transporte autenticado pertencem ao procedimento do host; nenhum destes passos foi executado em instalações persistentes por este incremento.
+
+Depois da preparação autorizada do destino, a ligação no host usa:
+
+```php
+require $packageDirectory.'/integration/bootstrap.php';
+
+$inbox = new Tempo\Integration\WarehouseInbox(
+ $dedicatedPdo, $configuredSourceId, $sourceTenantId,
+ $destinationTenantId, $configuredReaderGeneration,
+);
+$pull = new Tempo\Integration\WarehousePull($inbox);
+$result = $pull->run($fetchAuthenticatedPage, maxPages: 10, pageSize: 50);
+```
+
+`$fetchAuthenticatedPage` é uma função do host que recebe `(?string $cursor, int $limit)` e devolve a página JSON descodificada do endpoint configurado `/stock/integracao/entregas`, com a conta e contexto autorizados. Deve verificar origem, autenticação e resposta HTTP antes de devolver dados; o módulo não escolhe URLs, credenciais ou destinatários. Excepções interrompem a leitura e preservam as páginas já aceites. `has_more=true` no resultado indica continuação pendente pelo limite do lote, não conclusão do processamento.
+
+Os eventos guardados são documentos históricos reutilizáveis e consultáveis por `fact($idempotencyKey)`. Não são comandos para lançar stock, aprovar compras ou facturar. Confirmações exigem a entrega original reconciliada e não criam outra saída. O SHA-256 confere o conteúdo emitido pelo produtor, sem substituir a autenticação do canal. O adaptador não cria projecções de negócio nem confirmações funcionais no sistema de origem. Testes e provas em `tests/Unit/WarehouseInboxTest.php`, `tests/Feature/WarehouseExchangeTest.php` e `scripts/work-context-pg-check.php --inbox`, só com dados sintéticos/base descartável.
+
+### Consulta HTTP autenticada
+
+`WarehouseHttpSource` implementa a função de consulta para `WarehousePull`, usando PHP cURL e a sessão já autorizada na origem. O host fornece a origem HTTPS, a empresa e uma função que obtém o cabeçalho Cookie da sessão por canal protegido. A sessão deve ter o contexto Compras internas seleccionado quando aplicável; as mesmas verificações de conta, perfis, empresa e contexto da rota continuam activas. O cliente não faz login, selecciona contexto, cria contas, outorga permissões ou armazena credenciais.
+
+```php
+$source = new Tempo\Integration\WarehouseHttpSource(
+ $configuredHttpsOrigin, $sourceTenantId, $sessionCookieProvider,
+);
+$result = $pull->run($source, maxPages: 10, pageSize: 50);
+```
+
+O cliente consulta apenas GET /stock/integracao/entregas dentro da origem configurada. Cursor/limite são codificados como parâmetros, e as credenciais seguem apenas no cabeçalho, nunca na URL. A origem não admite userinfo, query, fragmento, escapes de caminho ou travessia. Não segue redireccionamentos. HTTPS confere certificado/hostname; a opção allowLoopbackHttp=true só permite HTTP em127.0.0.1 ou[::1] para desenvolvimento explícito, sem proxy nesse percurso local. O ensaio8005 usa esta excepção; não representa prova de certificados TLS de um destino comercial.
+
+Há limites técnicos configuráveis no construtor:15segundos totais,5de ligação e8MiB de resposta por defeito; tamanho é limitado também após descompressão. A resposta exige HTTP200, JSON e contrato/empresa/hash válidos. A função de credenciais é consultada novamente em cada pedido e a informação de depuração oculta-a. WarehouseTransportFailure oferece category/httpStatus/retryable(), sem corpo remoto, cookies ou erro original do fornecedor; falhas de acesso/cursor não desencadeiam reset, nova conta ou recuperação automática de autoridade. retryable é um diagnóstico técnico, não uma tentativa automática.
+
+A instalação permanente, configuração pela administração do host, armazenamento protegido da sessão, renovação após expiração e uma eventual autenticação técnica restrita ainda dependem dos seus percursos próprios. Esta implementação não oferece tokens permanentes, login automático, serviço agendado ou projecção de stock/contabilidade no destinatário. Registar essas lacunas ao entregar; preservar os perfis/aprovações e a instalação real do SGF. Prova local: scripts/warehouse-http-source-http.mjs obtém a sessão sintética existente e passa-a por stdin privado ao processo PHP; o receptor usa só SQLite em memória e a prova não grava a credencial.
diff --git a/packages/integration/bootstrap.php b/packages/integration/bootstrap.php
new file mode 100644
index 0000000..c23ada5
--- /dev/null
+++ b/packages/integration/bootstrap.php
@@ -0,0 +1,7 @@
+ $page */
+ public static function validate(array $page, int $tenant): void
+ {
+ self::check(($page['schema'] ?? null) === 'sgc.warehouse.fulfilment-feed.v1', 'Versão de feed incompatível.');
+ self::check(isset($page['items']) && is_array($page['items']) && array_is_list($page['items']) && count($page['items']) <= 100, 'Página inválida.');
+ self::check(is_bool($page['has_more'] ?? null), 'Indicador de paginação inválido.');
+ self::check(is_string($page['next_cursor'] ?? null) && strlen($page['next_cursor']) > 0 && strlen($page['next_cursor']) <= 4096, 'Cursor inválido.');
+ self::check(! $page['has_more'] || count($page['items']) > 0, 'Uma página vazia não pode prometer continuação.');
+ $previous = 0;
+ foreach ($page['items'] as $event) {
+ self::check(is_array($event), 'Evento inválido.');
+ self::event($event, $tenant);
+ self::check($event['event_id'] > $previous, 'Eventos fora de ordem ou repetidos na página.');
+ $previous = $event['event_id'];
+ }
+ }
+
+ /** @param array $event */
+ private static function event(array $event, int $tenant): void
+ {
+ self::check(($event['schema'] ?? null) === 'sgc.warehouse.fulfilment-event.v1' && ($event['tenant_id'] ?? null) === $tenant, 'Evento de outra versão ou empresa.');
+ self::positive($event['event_id'] ?? null);
+ self::positive($event['event_version'] ?? null);
+ self::check(($event['idempotency_key'] ?? null) === 'sgc:'.$tenant.':warehouse-event:'.$event['event_id'], 'Identidade do evento inválida.');
+ self::check(in_array($event['action'] ?? null, ['deliver', 'receive'], true), 'Acção incompatível.');
+ self::check(is_string($event['occurred_at'] ?? null) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/D', $event['occurred_at']) === 1, 'Data do evento inválida.');
+ $date = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:sP', $event['occurred_at']);
+ $errors = \DateTimeImmutable::getLastErrors();
+ self::check($date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0)), 'Data do evento inexistente.');
+ self::actor($event['actor'] ?? null);
+ $request = $event['request'] ?? null;
+ self::check(is_array($request), 'Requisição inválida.');
+ self::positive($request['id'] ?? null);
+ self::check(($request['reference'] ?? null) === 'RA-'.$request['id'] && ($request['document_type'] ?? null) === 'internal_warehouse_request', 'Documento de origem inválido.');
+ self::check(array_key_exists('work_context_key', $request) && in_array($request['work_context_key'], ['procurement.internal', null], true), 'Contexto incompatível.');
+ self::actor($request['requester'] ?? null);
+ self::registry($request['warehouse'] ?? null);
+ self::registry($request['cost_center'] ?? null);
+ self::check(is_string($request['destination'] ?? null), 'Destino inválido.');
+ self::check(isset($event['lines']) && is_array($event['lines']) && array_is_list($event['lines']) && count($event['lines']) <= 50, 'Linhas inválidas.');
+ self::check(array_key_exists('delivery_event_key', $event), 'Referência à entrega em falta.');
+ if ($event['action'] === 'deliver') {
+ self::check(count($event['lines']) > 0 && $event['delivery_event_key'] === null, 'Entrega sem linhas ou com referência indevida.');
+ $indexes = [];
+ foreach ($event['lines'] as $line) {
+ self::check(is_array($line) && is_int($line['request_line_index'] ?? null) && $line['request_line_index'] >= 0, 'Linha de origem inválida.');
+ self::check(! isset($indexes[$line['request_line_index']]), 'Linha de origem repetida.');
+ $indexes[$line['request_line_index']] = true;
+ self::registry($line['item'] ?? null);
+ self::check(is_string($line['item']['unit'] ?? null) && is_string($line['destination'] ?? null), 'Unidade ou destino em falta.');
+ self::positive($line['quantity_milli'] ?? null);
+ self::positive($line['stock_operation_id'] ?? null);
+ self::check(in_array($line['use'] ?? null, ['internal', 'equipment'], true) && array_key_exists('equipment', $line), 'Utilização inválida.');
+ if ($line['use'] === 'equipment') {
+ self::check(is_array($line['equipment']), 'Equipamento em falta.');
+ self::positive($line['equipment']['id'] ?? null);
+ self::check(is_string($line['equipment']['code'] ?? null) && is_string($line['equipment']['description'] ?? null), 'Equipamento inválido.');
+ } else {
+ self::check($line['equipment'] === null, 'Equipamento incompatível com utilização interna.');
+ }
+ }
+ } else {
+ self::check($event['lines'] === [] && is_string($event['delivery_event_key'])
+ && preg_match('/^sgc:'.preg_quote((string) $tenant, '/').':warehouse-event:[1-9][0-9]*$/D', $event['delivery_event_key']) === 1, 'Confirmação sem referência válida ou com nova saída.');
+ }
+ $payload = $event;
+ unset($payload['payload_sha256']);
+ self::check(is_string($event['payload_sha256'] ?? null) && hash_equals(hash('sha256', self::json($payload)), $event['payload_sha256']), 'Conteúdo do evento alterado.');
+ }
+
+ /** @param array $value */
+ public static function json(array $value): string
+ {
+ return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ private static function positive(mixed $value): void
+ {
+ self::check(is_int($value) && $value > 0, 'Identificador ou quantidade inválidos.');
+ }
+
+ private static function actor(mixed $value): void
+ {
+ self::check(is_array($value), 'Interveniente em falta.');
+ self::positive($value['id'] ?? null);
+ self::check(is_string($value['name'] ?? null), 'Nome do interveniente em falta.');
+ }
+
+ private static function registry(mixed $value): void
+ {
+ self::actor($value);
+ self::check(is_string($value['code'] ?? null), 'Código do cadastro em falta.');
+ }
+
+ private static function check(bool $valid, string $message): void
+ {
+ if (! $valid) {
+ throw new InvalidArgumentException($message);
+ }
+ }
+}
diff --git a/packages/integration/src/WarehouseHttpSource.php b/packages/integration/src/WarehouseHttpSource.php
new file mode 100644
index 0000000..0d43d05
--- /dev/null
+++ b/packages/integration/src/WarehouseHttpSource.php
@@ -0,0 +1,138 @@
+ 120000 || $connectTimeoutMs < 100 || $connectTimeoutMs > $timeoutMs
+ || $maxResponseBytes < 1024 || $maxResponseBytes > 16777216) {
+ throw new InvalidArgumentException('Âmbito ou limites de transporte inválidos.');
+ }
+ if (! extension_loaded('curl') || ! defined('CURLOPT_PROTOCOLS_STR')) {
+ throw new InvalidArgumentException('O transporte de integração exige PHP cURL com selecção de protocolos.');
+ }
+ $this->endpoint = rtrim($baseUrl, '/').'/stock/integracao/entregas';
+ $provider = Closure::fromCallable($sessionCookie);
+ $this->sessionCookie = static fn (): string => $provider();
+ $this->directLoopback = in_array(strtolower($parts['host']), ['127.0.0.1', '[::1]'], true);
+ }
+
+ /** @return array */
+ public function __invoke(?string $cursor, int $limit): array
+ {
+ if ($limit < 1 || $limit > 100 || ($cursor !== null && (strlen($cursor) === 0 || strlen($cursor) > 4096))) {
+ throw new InvalidArgumentException('Cursor ou limite de leitura inválidos.');
+ }
+ try {
+ $cookie = ($this->sessionCookie)();
+ } catch (Throwable) {
+ throw new WarehouseTransportFailure('credentials');
+ }
+ if (strlen($cookie) === 0 || strlen($cookie) > 16384 || preg_match('/[^\x20-\x7e]/', $cookie)) {
+ throw new WarehouseTransportFailure('credentials');
+ }
+ $query = ['limit' => $limit];
+ if ($cursor !== null) {
+ $query['cursor'] = $cursor;
+ }
+ $handle = curl_init($this->endpoint.'?'.http_build_query($query, encoding_type: PHP_QUERY_RFC3986));
+ if ($handle === false) {
+ throw new WarehouseTransportFailure('connection');
+ }
+ $body = '';
+ $oversized = false;
+ curl_setopt_array($handle, [
+ CURLOPT_HTTPGET => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0,
+ CURLOPT_PROTOCOLS_STR => 'http,https', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
+ CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs, CURLOPT_TIMEOUT_MS => $this->timeoutMs,
+ CURLOPT_ENCODING => '', CURLOPT_HTTPHEADER => ['Accept: application/json', 'Cookie: '.$cookie],
+ CURLOPT_WRITEFUNCTION => function ($handle, string $chunk) use (&$body, &$oversized): int {
+ if (strlen($body) + strlen($chunk) > $this->maxResponseBytes) {
+ $oversized = true;
+
+ return 0;
+ }
+ $body .= $chunk;
+
+ return strlen($chunk);
+ },
+ ]);
+ if ($this->directLoopback) {
+ curl_setopt($handle, CURLOPT_PROXY, '');
+ }
+ $ok = curl_exec($handle);
+ $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
+ if ($oversized) {
+ throw new WarehouseTransportFailure('response_size', $status ?: null);
+ }
+ if ($ok === false) {
+ throw new WarehouseTransportFailure(curl_errno($handle) === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'connection', $status ?: null);
+ }
+ if ($status !== 200) {
+ $category = match (true) {
+ $status >= 300 && $status < 400 => 'redirect',
+ in_array($status, [401, 419], true) => 'authentication',
+ $status === 403 => 'authorization',
+ in_array($status, [409, 422], true) => 'cursor',
+ $status === 429 || $status >= 500 => 'remote_unavailable',
+ default => 'http',
+ };
+ throw new WarehouseTransportFailure($category, $status);
+ }
+ $type = curl_getinfo($handle, CURLINFO_CONTENT_TYPE);
+ if (! is_string($type) || ! preg_match('~^application/json(?:\s*;.*)?$~iD', $type)) {
+ throw new WarehouseTransportFailure('response_contract', 200);
+ }
+ try {
+ $page = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
+ if (! is_array($page)) {
+ throw new InvalidArgumentException;
+ }
+ WarehouseFeed::validate($page, $this->sourceTenant);
+ } catch (Throwable) {
+ throw new WarehouseTransportFailure('response_contract', 200);
+ }
+
+ return $page;
+ }
+
+ /** @return array */
+ public function __debugInfo(): array
+ {
+ return ['endpoint' => $this->endpoint, 'source_tenant' => $this->sourceTenant, 'session' => '[protected]'];
+ }
+}
diff --git a/packages/integration/src/WarehouseInbox.php b/packages/integration/src/WarehouseInbox.php
new file mode 100644
index 0000000..82c6230
--- /dev/null
+++ b/packages/integration/src/WarehouseInbox.php
@@ -0,0 +1,159 @@
+ */
+ private array $scope;
+
+ public function __construct(private PDO $db, string $source, int $sourceTenant, int $destinationTenant, string $consumer)
+ {
+ if (! in_array($db->getAttribute(PDO::ATTR_DRIVER_NAME), ['sqlite', 'pgsql'], true) || $db->getAttribute(PDO::ATTR_ERRMODE) !== PDO::ERRMODE_EXCEPTION
+ || ! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/D', $source)
+ || ! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/D', $consumer)
+ || $sourceTenant <= 0 || $destinationTenant <= 0) {
+ throw new InvalidArgumentException('Ligação ou âmbito de integração inválidos.');
+ }
+ $this->scope = ['source_id' => $source, 'source_tenant_id' => $sourceTenant, 'destination_tenant_id' => $destinationTenant, 'consumer_id' => $consumer];
+ }
+
+ /** Explicit host migration input; construction/consumption never installs schema.
+ * @return list
+ */
+ public static function schema(): array
+ {
+ return ['CREATE TABLE sgc_exchange_streams (source_id VARCHAR(100) NOT NULL, source_tenant_id BIGINT NOT NULL, destination_tenant_id BIGINT NOT NULL, consumer_id VARCHAR(100) NOT NULL, version BIGINT NOT NULL, cursor TEXT NULL, last_event_id BIGINT NOT NULL, last_page_sha256 VARCHAR(64) NULL, PRIMARY KEY (source_id, source_tenant_id, destination_tenant_id, consumer_id))',
+ 'CREATE TABLE sgc_exchange_facts (source_id VARCHAR(100) NOT NULL, source_tenant_id BIGINT NOT NULL, destination_tenant_id BIGINT NOT NULL, event_key VARCHAR(200) NOT NULL, event_id BIGINT NOT NULL, request_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL, delivery_event_key VARCHAR(200) NULL, payload_sha256 VARCHAR(64) NOT NULL, payload TEXT NOT NULL, recorded_at VARCHAR(40) NOT NULL, PRIMARY KEY (source_id, source_tenant_id, destination_tenant_id, event_key), UNIQUE (source_id, source_tenant_id, destination_tenant_id, delivery_event_key))'];
+ }
+
+ /** @return array{version:int,cursor:?string,last_event_id:int} */
+ public function checkpoint(): array
+ {
+ $row = $this->stream(false);
+
+ return ['version' => $row === false ? 0 : (int) $row['version'], 'cursor' => $row === false ? null : $row['cursor'], 'last_event_id' => $row === false ? 0 : (int) $row['last_event_id']];
+ }
+
+ /** @param array $page
+ * @return array{inserted:int,replayed:bool,version:int,cursor:string,has_more:bool}
+ */
+ public function accept(array $page, int $expectedVersion, ?string $requestedCursor): array
+ {
+ WarehouseFeed::validate($page, (int) $this->scope['source_tenant_id']);
+ if ($expectedVersion < 0 || $this->db->inTransaction()) {
+ throw new InvalidArgumentException('Versão inválida ou transacção externa activa.');
+ }
+ $digest = hash('sha256', WarehouseFeed::json($page));
+ $this->db->beginTransaction();
+ try {
+ $this->execute('INSERT INTO sgc_exchange_streams (source_id, source_tenant_id, destination_tenant_id, consumer_id, version, cursor, last_event_id) VALUES (:source_id, :source_tenant_id, :destination_tenant_id, :consumer_id, 0, NULL, 0) ON CONFLICT DO NOTHING', $this->scope);
+ $stream = $this->stream(true);
+ if ($stream === false) {
+ throw new RuntimeException('Ponto de leitura indisponível.');
+ }
+ if ((int) $stream['version'] !== $expectedVersion) {
+ if ((int) $stream['version'] === $expectedVersion + 1 && $stream['last_page_sha256'] === $digest) {
+ $this->db->commit();
+
+ return ['inserted' => 0, 'replayed' => true, 'version' => (int) $stream['version'], 'cursor' => $stream['cursor'], 'has_more' => $page['has_more']];
+ }
+ throw new RuntimeException('A leitura avançou noutro executor. Releia o ponto guardado.');
+ }
+ if ($stream['cursor'] !== $requestedCursor) {
+ throw new RuntimeException('A página não corresponde ao ponto de leitura solicitado.');
+ }
+ $last = (int) $stream['last_event_id'];
+ $inserted = 0;
+ foreach ($page['items'] as $event) {
+ if ($event['event_id'] <= $last) {
+ throw new RuntimeException('Página antiga: o ponto guardado não pode recuar.');
+ }
+ $existing = $this->fact($event['idempotency_key']);
+ if ($existing !== null && $existing['payload_sha256'] !== $event['payload_sha256']) {
+ throw new RuntimeException('A mesma identidade tem conteúdo diferente. Reconciliação necessária.');
+ }
+ if ($event['action'] === 'receive') {
+ $delivery = $this->fact($event['delivery_event_key']);
+ if ($delivery === null || $delivery['action'] !== 'deliver' || $delivery['request'] !== $event['request'] || $delivery['event_id'] >= $event['event_id']) {
+ throw new RuntimeException('Confirmação sem entrega de origem reconciliada.');
+ }
+ }
+ if ($existing === null) {
+ $bindings = $this->factScope();
+ $bindings += ['event_key' => $event['idempotency_key'], 'event_id' => $event['event_id'], 'request_id' => $event['request']['id'], 'action' => $event['action'], 'delivery_event_key' => $event['delivery_event_key'],
+ 'payload_sha256' => $event['payload_sha256'], 'payload' => WarehouseFeed::json($event), 'recorded_at' => gmdate('c')];
+ $written = $this->execute('INSERT INTO sgc_exchange_facts (source_id, source_tenant_id, destination_tenant_id, event_key, event_id, request_id, action, delivery_event_key, payload_sha256, payload, recorded_at) VALUES (:source_id, :source_tenant_id, :destination_tenant_id, :event_key, :event_id, :request_id, :action, :delivery_event_key, :payload_sha256, :payload, :recorded_at) ON CONFLICT DO NOTHING', $bindings);
+ // Another stream may have inserted this identity concurrently.
+ $saved = $this->fact($event['idempotency_key']);
+ if ($saved === null || $saved['payload_sha256'] !== $event['payload_sha256']) {
+ throw new RuntimeException('Conflito de conteúdo entre consumidores.');
+ }
+ $inserted += $written->rowCount();
+ }
+ $last = $event['event_id'];
+ }
+ $statement = $this->execute('UPDATE sgc_exchange_streams SET version = version + 1, cursor = :cursor, last_event_id = :last_event_id, last_page_sha256 = :digest WHERE '.$this->streamWhere().' AND version = :expected_version',
+ $this->scope + ['cursor' => $page['next_cursor'], 'last_event_id' => $last, 'digest' => $digest, 'expected_version' => $expectedVersion]);
+ if ($statement->rowCount() !== 1) {
+ throw new RuntimeException('O ponto de leitura mudou durante a recepção.');
+ }
+ $this->db->commit();
+
+ return ['inserted' => $inserted, 'replayed' => false, 'version' => $expectedVersion + 1, 'cursor' => $page['next_cursor'], 'has_more' => $page['has_more']];
+ } catch (Throwable $e) {
+ $this->rollbackOpenTransaction();
+ throw $e;
+ }
+ }
+
+ /** @return array|null */
+ public function fact(string $key): ?array
+ {
+ $row = $this->execute('SELECT payload FROM sgc_exchange_facts WHERE source_id = :source_id AND source_tenant_id = :source_tenant_id AND destination_tenant_id = :destination_tenant_id AND event_key = :event_key', $this->factScope() + ['event_key' => $key])->fetch(PDO::FETCH_ASSOC);
+
+ return $row === false ? null : json_decode($row['payload'], true, 512, JSON_THROW_ON_ERROR);
+ }
+
+ /** @return array|false */
+ private function stream(bool $lock): array|false
+ {
+ return $this->execute('SELECT version, cursor, last_event_id, last_page_sha256 FROM sgc_exchange_streams WHERE '.$this->streamWhere().($lock && $this->db->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : ''), $this->scope)->fetch(PDO::FETCH_ASSOC);
+ }
+
+ /** @return literal-string */
+ private function streamWhere(): string
+ {
+ return 'source_id = :source_id AND source_tenant_id = :source_tenant_id AND destination_tenant_id = :destination_tenant_id AND consumer_id = :consumer_id';
+ }
+
+ /** @return array */
+ private function factScope(): array
+ {
+ return array_diff_key($this->scope, ['consumer_id' => true]);
+ }
+
+ /** @param literal-string $sql
+ * @param array $bindings
+ */
+ private function execute(string $sql, array $bindings): \PDOStatement
+ {
+ $statement = $this->db->prepare($sql);
+ $statement->execute($bindings);
+
+ return $statement;
+ }
+
+ private function rollbackOpenTransaction(): void
+ {
+ if ($this->db->inTransaction()) {
+ $this->db->rollBack();
+ }
+ }
+}
diff --git a/packages/integration/src/WarehousePull.php b/packages/integration/src/WarehousePull.php
new file mode 100644
index 0000000..8ba43fa
--- /dev/null
+++ b/packages/integration/src/WarehousePull.php
@@ -0,0 +1,34 @@
+ $fetch
+ * @return array{pages:int,inserted:int,replayed_pages:int,has_more:bool,checkpoint:array{version:int,cursor:?string,last_event_id:int}}
+ */
+ public function run(callable $fetch, int $maxPages = 1, int $pageSize = 50): array
+ {
+ if ($maxPages < 1 || $maxPages > 100 || $pageSize < 1 || $pageSize > 100) {
+ throw new InvalidArgumentException('Limites de leitura inválidos.');
+ }
+ $pages = 0;
+ $inserted = 0;
+ $replayed = 0;
+ do {
+ $checkpoint = $this->inbox->checkpoint();
+ $page = $fetch($checkpoint['cursor'], $pageSize);
+ $result = $this->inbox->accept($page, $checkpoint['version'], $checkpoint['cursor']);
+ $pages++;
+ $inserted += $result['inserted'];
+ $replayed += $result['replayed'] ? 1 : 0;
+ } while ($result['has_more'] && $pages < $maxPages);
+
+ return ['pages' => $pages, 'inserted' => $inserted, 'replayed_pages' => $replayed, 'has_more' => $result['has_more'], 'checkpoint' => $this->inbox->checkpoint()];
+ }
+}
diff --git a/packages/integration/src/WarehouseTransportFailure.php b/packages/integration/src/WarehouseTransportFailure.php
new file mode 100644
index 0000000..7f1e3f4
--- /dev/null
+++ b/packages/integration/src/WarehouseTransportFailure.php
@@ -0,0 +1,30 @@
+ 'A sessão autorizada está indisponível ou é inválida.',
+ 'authentication' => 'A origem exige uma sessão autenticada válida.',
+ 'authorization' => 'A origem recusou o acesso aos eventos neste âmbito.',
+ 'redirect' => 'A origem devolveu um redireccionamento. Confirme a ligação e a sessão.',
+ 'cursor' => 'A origem recusou o cursor ou os parâmetros da leitura.',
+ 'timeout' => 'A consulta à origem excedeu o tempo permitido.',
+ 'remote_unavailable' => 'A origem está temporariamente indisponível.',
+ 'response_size' => 'A resposta da origem excedeu o limite de leitura.',
+ 'response_contract' => 'A resposta da origem não corresponde ao contrato de entregas.',
+ default => 'Não foi possível consultar a origem de integração.',
+ });
+ }
+
+ public function retryable(): bool
+ {
+ return in_array($this->category, ['timeout', 'connection', 'remote_unavailable'], true);
+ }
+}
diff --git a/packages/inventory-issue/releases/0.1.0/bootstrap.php b/packages/inventory-issue/releases/0.1.0/bootstrap.php
new file mode 100644
index 0000000..72950d0
--- /dev/null
+++ b/packages/inventory-issue/releases/0.1.0/bootstrap.php
@@ -0,0 +1,2 @@
+db->transactionLevel()<1)throw new \LogicException('Issue requires the host transaction');($this->authorize)();}
+ private function operation(int $id,bool $lock=false): object
+ {
+ $q=$this->db->table($this->schema.'.stock_operations')->where(['tenant_id'=>$this->tenant,'id'=>$id]);if($lock)$q->lockForUpdate();
+ $o=$q->first();abort_unless($o,403);abort_unless($o->kind==='issue'&&(int)$o->created_by===$this->actor&&BigDecimal::of((string)$o->quantity_milli)->isPositive(),409,'Operação incompatível com saída física.');return $o;
+ }
+ public function preview(int $id): string {$this->host();return hash('sha256',json_encode((array)$this->operation($id),JSON_THROW_ON_ERROR));}
+ public function write(int $id,string $hash): array
+ {
+ $this->host();$o=$this->operation($id);
+ // Lock the actual warehouse, including a previously empty book. Hosts keep their company lock too.
+ $warehouse=$this->db->table($this->schema.'.warehouses')->where(['tenant_id'=>$this->tenant,'id'=>$o->warehouse_id])->lockForUpdate()->first();abort_unless($warehouse,403);
+ abort_unless($this->db->table($this->schema.'.stock_items')->where(['tenant_id'=>$this->tenant,'id'=>$o->stock_item_id])->lockForUpdate()->first(),403);
+ $o=$this->operation($id,true);abort_unless(hash_equals($hash,hash('sha256',json_encode((array)$o,JSON_THROW_ON_ERROR))),409,'A operação mudou. Faça nova conferência.');
+ $q=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'warehouse_id'=>$o->warehouse_id]);$old=$q->first();
+ $negative=(string)BigDecimal::of((string)$o->quantity_milli)->negated();
+ if($old){$s=json_decode($old->snapshot,true,512,JSON_THROW_ON_ERROR);abort_unless(($s['inventory_operation_hash']??null)===$hash&&(string)$old->quantity_milli===$negative&&(int)$old->stock_item_id===(int)$o->stock_item_id,409,'Saída existente incompatível; exige reconciliação.');return ['movement_id'=>(int)$old->id,'replayed'=>true];}
+ $book=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id])->selectRaw('coalesce(sum(quantity_milli),0) AS quantity,coalesce(max(id),0) AS revision')->first();
+ abort_unless((string)$o->ledger_version===(string)$book->revision,409,'O livro mudou. Faça nova conferência.');
+ abort_unless(BigDecimal::of((string)$book->quantity)->isGreaterThanOrEqualTo((string)$o->quantity_milli),409,'Stock físico insuficiente.');
+ $snapshot=json_decode($o->snapshot,true,512,JSON_THROW_ON_ERROR)+['reference'=>$o->reference,'reason'=>$o->reason,'inventory_operation_hash'=>$hash];
+ $movement=$this->db->table($this->schema.'.stock_movements')->insertGetId(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id,
+ 'equipment_id'=>$o->equipment_id??null,'spare_part_id'=>$o->spare_part_id??null,'moved_by'=>$this->actor,'movement_type'=>'issue','quantity_milli'=>$negative,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'moved_at'=>now()]);
+ return ['movement_id'=>(int)$movement,'replayed'=>false];
+ }
+}
diff --git a/packages/inventory-read/releases/0.1.0/bootstrap.php b/packages/inventory-read/releases/0.1.0/bootstrap.php
new file mode 100644
index 0000000..dfb715d
--- /dev/null
+++ b/packages/inventory-read/releases/0.1.0/bootstrap.php
@@ -0,0 +1,3 @@
+db->transaction(function () use ($binding,$fingerprint,$filters,$token) {
+ abort_unless($this->db->transactionLevel() === 1,503,'Consulta de stock exige transacção de leitura própria.');
+ $this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
+ $this->db->statement("SET LOCAL statement_timeout='3s'");
+ $reader = new InventoryReader($this->db,$binding['schema'],$binding['source_tenant_id'],$binding['warehouse_ids']);
+ try { $revision = $reader->revision(); }
+ catch (\UnexpectedValueException) { abort(503,'Fonte de stock incompatível.'); }
+ $observed = time();
+ if ($token !== null) {
+ try { $cut = json_decode(Crypt::decryptString($token),true,512,JSON_THROW_ON_ERROR); }
+ catch (\Throwable) { abort(409,'A referência de consulta é inválida. Actualize a consulta.'); }
+ abort_unless(($cut['fingerprint'] ?? null) === $fingerprint && ($cut['revision'] ?? null) === $revision
+ && is_int($cut['observed'] ?? null) && $cut['observed'] <= time() && $cut['observed'] >= time()-600,409,'A fonte ou o contexto mudou. Actualize a consulta antes de continuar.');
+ $observed = $cut['observed'];
+ }
+ $snapshot = Crypt::encryptString(json_encode(['fingerprint'=>$fingerprint,'revision'=>$revision,'observed'=>$observed],JSON_THROW_ON_ERROR));
+ return $reader->page($filters) + ['status'=>'available','synthetic_only'=>true,'view'=>$filters['view'],'snapshot'=>$snapshot,
+ 'observed_at'=>gmdate('c',$observed),'ledger_revision'=>$revision,'capabilities'=>['write'=>false,'valuation'=>false,'reserved'=>false,'blocked'=>false],
+ 'notice'=>'Consulta sintética. Disponível indeterminado; pares sem movimentos não são saldos zero. Sem movimentos ou custos nesta interface.'];
+ });
+ }
+}
diff --git a/packages/inventory-read/releases/0.1.0/src/InventoryReader.php b/packages/inventory-read/releases/0.1.0/src/InventoryReader.php
new file mode 100644
index 0000000..ce5dd26
--- /dev/null
+++ b/packages/inventory-read/releases/0.1.0/src/InventoryReader.php
@@ -0,0 +1,75 @@
+ 1000
+ || array_filter($warehouses, fn ($id) => !is_int($id) || $id < 1)) throw new \InvalidArgumentException('Invalid inventory source scope');
+ }
+
+ private function table(string $name) { return $this->db->table($this->schema.'.'.$name); }
+ private function movements()
+ {
+ return $this->table('stock_movements')->where('stock_movements.tenant_id', $this->tenant)->whereIn('warehouse_id', $this->warehouses);
+ }
+
+ /** Must be called inside the host's read-only repeatable-read transaction. */
+ public function revision(): array
+ {
+ $ledger = $this->movements()->selectRaw('count(*)::text AS count,coalesce(max(id),0)::text AS last')->first();
+ // Metadata is hashed in SQL, not materialised as a whole catalogue in PHP.
+ $items = $this->table('stock_items')->where('tenant_id', $this->tenant)
+ ->selectRaw("md5(coalesce(string_agg(jsonb_build_array(id,code,name,unit)::text, '' ORDER BY id),'')) AS hash")->first();
+ $warehouses = $this->table('warehouses')->where('tenant_id', $this->tenant)->whereIn('id', $this->warehouses)
+ ->selectRaw("count(*) AS count,md5(coalesce(string_agg(jsonb_build_array(id,code,name)::text, '' ORDER BY id),'')) AS hash")->first();
+ if ((int) $warehouses->count !== count(array_unique($this->warehouses))) throw new \UnexpectedValueException('Unmapped inventory warehouse');
+ return ['ledger_count' => $ledger->count, 'ledger_last' => $ledger->last, 'catalogue' => $items->hash, 'warehouses' => $warehouses->hash];
+ }
+
+ public function page(array $filters): array
+ {
+ $view = $filters['view']; $warehouse = $filters['warehouse_id'] ?? null;
+ if ($warehouse !== null && !in_array($warehouse, $this->warehouses, true)) throw new \DomainException('Warehouse outside authorised source');
+ if ($view === 'items') {
+ $q = $this->table('stock_items')->where('tenant_id', $this->tenant)->select(['id','code','name','unit'])->orderBy('id');
+ } elseif ($view === 'warehouses') {
+ $q = $this->table('warehouses')->where('tenant_id', $this->tenant)->whereIn('id', $this->warehouses)->select(['id','code','name'])->orderBy('id');
+ } elseif (in_array($view, ['balances','movements'], true)) {
+ $q = $this->movements()
+ ->join($this->schema.'.stock_items AS i', fn ($j) => $j->on('i.id','=','stock_movements.stock_item_id')->on('i.tenant_id','=','stock_movements.tenant_id'))
+ ->join($this->schema.'.warehouses AS w', fn ($j) => $j->on('w.id','=','stock_movements.warehouse_id')->on('w.tenant_id','=','stock_movements.tenant_id'));
+ if ($warehouse !== null) $q->where('stock_movements.warehouse_id', $warehouse);
+ if ($view === 'balances') {
+ $q->selectRaw('stock_movements.stock_item_id,stock_movements.warehouse_id,i.code,i.name,i.unit,w.code AS warehouse_code,w.name AS warehouse_name,SUM(stock_movements.quantity_milli)::text AS physical_milli,MAX(stock_movements.id)::text AS ledger_version')
+ ->groupBy('stock_movements.stock_item_id','stock_movements.warehouse_id','i.code','i.name','i.unit','w.code','w.name')
+ ->orderBy('stock_movements.stock_item_id')->orderBy('stock_movements.warehouse_id');
+ } else {
+ $q->select(['stock_movements.id','stock_movements.stock_item_id','stock_movements.warehouse_id','i.code','i.name','i.unit','w.code AS warehouse_code','w.name AS warehouse_name',
+ 'stock_movements.movement_type','stock_movements.moved_at'])
+ ->selectRaw('stock_movements.quantity_milli::text AS quantity_milli')
+ // Only safe provenance; never return the raw document/financial snapshot.
+ ->selectRaw("stock_movements.snapshot->>'reference' AS source_reference")
+ ->orderBy('stock_movements.id');
+ }
+ } else throw new \InvalidArgumentException('Unsupported inventory view');
+ $search = $filters['search'] ?? '';
+ if ($search !== '') {
+ $text = '%'.str_replace(['\\','%','_'], ['\\\\','\\%','\\_'], $search).'%';
+ $prefix = in_array($view, ['items','warehouses'], true) ? '' : 'i.';
+ $q->where(fn ($s) => $s->where($prefix.'code','ilike',$text)->orWhere($prefix.'name','ilike',$text));
+ }
+ $total = (int) $this->db->query()->fromSub((clone $q)->reorder(), 'inventory_rows')->count();
+ $perPage = $filters['per_page']; $page = $filters['page'];
+ $rows = $q->limit($perPage)->offset(($page-1)*$perPage)->get()->map(fn ($r) => (array) $r)->all();
+ if ($view === 'balances') foreach ($rows as &$row) {
+ $row += ['reserved_milli' => null, 'blocked_milli' => null, 'available_milli' => null];
+ }
+ return ['rows' => $rows, 'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage, 'last_page' => max(1, (int) ceil($total/$perPage))]];
+ }
+}
diff --git a/packages/inventory-return/releases/0.1.0/bootstrap.php b/packages/inventory-return/releases/0.1.0/bootstrap.php
new file mode 100644
index 0000000..3335a0a
--- /dev/null
+++ b/packages/inventory-return/releases/0.1.0/bootstrap.php
@@ -0,0 +1,2 @@
+db->transactionLevel()<1)throw new \LogicException('Return requires host transaction');($this->authorize)();}
+ private function hash(object $o):string {return hash('sha256',json_encode((array)$o,JSON_THROW_ON_ERROR));}
+ private function operation(int $id,bool $lock=false):object
+ {
+ $q=$this->db->table($this->schema.'.stock_operations')->where(['tenant_id'=>$this->tenant,'id'=>$id]);if($lock)$q->lockForUpdate();$o=$q->first();
+ abort_unless($o&&$o->kind==='workshop_return'&&(int)$o->created_by===$this->actor&&BigDecimal::of((string)$o->quantity_milli)->isPositive(),409,'Operação de devolução incompatível.');return $o;
+ }
+ public function preview(int $id):string {$this->host();return $this->hash($this->operation($id));}
+ public function write(int $id,string $expected):array
+ {
+ $this->host();$o=$this->operation($id);
+ abort_unless($this->db->table($this->schema.'.warehouses')->where(['tenant_id'=>$this->tenant,'id'=>$o->warehouse_id])->lockForUpdate()->first(),403);
+ abort_unless($this->db->table($this->schema.'.stock_items')->where(['tenant_id'=>$this->tenant,'id'=>$o->stock_item_id])->lockForUpdate()->first(),403);
+ $o=$this->operation($id,true);$hash=$this->hash($o);abort_unless(hash_equals($expected,$hash),409,'Operação de devolução alterada.');
+ $snapshot=json_decode($o->snapshot,true,512,JSON_THROW_ON_ERROR);$parentId=$snapshot['delivery_movement_id']??null;abort_unless(is_int($parentId)&&$parentId>0,409);
+ abort_unless(($snapshot['received_in_warehouse']??false)===true,409,'Recepção física da devolução não declarada.');
+ $parent=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'id'=>$parentId,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id])->lockForUpdate()->first();
+ abort_unless($parent&&$parent->movement_type==='issue'&&BigDecimal::of((string)$parent->quantity_milli)->isNegative()&&$this->hash($parent)===($snapshot['delivery_movement_hash']??null),409,'Entrega original incompatível ou alterada.');
+ $old=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'warehouse_id'=>$o->warehouse_id])->first();
+ if($old){$s=json_decode($old->snapshot,true,512,JSON_THROW_ON_ERROR);abort_unless(($s['inventory_return_hash']??null)===$hash&&(string)$old->quantity_milli===(string)$o->quantity_milli&&(int)$old->stock_item_id===(int)$o->stock_item_id,409,'Devolução existente incompatível.');return ['movement_id'=>(int)$old->id,'replayed'=>true];}
+ $returned=(string)$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'movement_type'=>'workshop_return_in','snapshot->delivery_movement_id'=>$parentId])->sum('quantity_milli');
+ abort_unless(BigDecimal::of((string)$parent->quantity_milli)->negated()->minus($returned)->isGreaterThanOrEqualTo((string)$o->quantity_milli),409,'Devolução excede a quantidade entregue.');
+ $book=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id])->selectRaw('coalesce(max(id),0) AS revision')->first();
+ abort_unless((string)$o->ledger_version===(string)$book->revision,409,'Livro alterado; confira novamente.');
+ $movement=$this->db->table($this->schema.'.stock_movements')->insertGetId(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id,'moved_by'=>$this->actor,'movement_type'=>'workshop_return_in','quantity_milli'=>$o->quantity_milli,'snapshot'=>json_encode($snapshot+['inventory_return_hash'=>$hash,'reason'=>$o->reason],JSON_THROW_ON_ERROR),'moved_at'=>now()]);
+ return ['movement_id'=>(int)$movement,'replayed'=>false];
+ }
+}
diff --git a/packages/inventory-write/releases/0.1.0/bootstrap.php b/packages/inventory-write/releases/0.1.0/bootstrap.php
new file mode 100644
index 0000000..9bb87f7
--- /dev/null
+++ b/packages/inventory-write/releases/0.1.0/bootstrap.php
@@ -0,0 +1,2 @@
+checkHost();$facts=$this->facts($lineId,false);
+ return ['source_hash'=>$this->hash($facts),'receipt_item_id'=>$lineId,'accepted_milli'=>(string)$facts['accepted_milli'],
+ 'stock_item_id'=>(int)$facts['stock_item_id'],'warehouse_id'=>(int)$facts['warehouse_id']];
+ }
+
+ public function write(int $lineId, string $expectedHash): array
+ {
+ $this->checkHost();$facts=$this->facts($lineId,true);$hash=$this->hash($facts);
+ abort_unless(hash_equals($hash,$expectedHash),409,'A origem da recepção mudou. Faça nova conferência.');
+ $existing=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'goods_receipt_item_id'=>$lineId])->first();
+ if($existing){
+ $snapshot=json_decode($existing->snapshot,true,512,JSON_THROW_ON_ERROR);
+ abort_unless(($snapshot['inventory_source_hash']??null)===$hash && (string)$existing->quantity_milli===(string)$facts['accepted_milli']
+ && (int)$existing->stock_item_id===(int)$facts['stock_item_id'] && (int)$existing->warehouse_id===(int)$facts['warehouse_id'],409,'A origem já tem movimento incompatível ou histórico não reconciliado.');
+ return ['movement_id'=>(int)$existing->id,'source_hash'=>$hash,'replayed'=>true];
+ }
+ $lineSnapshot=json_decode($facts['line_snapshot'],true,512,JSON_THROW_ON_ERROR);
+ $receiptSnapshot=json_decode($facts['receipt_snapshot'],true,512,JSON_THROW_ON_ERROR);
+ $snapshot=[...$lineSnapshot,'receipt_id'=>(int)$facts['receipt_id'],'order_number'=>$receiptSnapshot['order']['number']??null,
+ 'delivery_note'=>$facts['delivery_note'],'reference'=>$facts['delivery_note'],'inventory_source_hash'=>$hash];
+ $id=$this->db->table($this->schema.'.stock_movements')->insertGetId([
+ 'tenant_id'=>$this->tenant,'goods_receipt_item_id'=>$lineId,'stock_item_id'=>$facts['stock_item_id'],'warehouse_id'=>$facts['warehouse_id'],
+ 'moved_by'=>$this->actor,'quantity_milli'=>$facts['accepted_milli'],'movement_type'=>'receipt_in','moved_at'=>now(),
+ 'equipment_id'=>$lineSnapshot['equipment_id']??null,'spare_part_id'=>$lineSnapshot['spare_part_id']??null,
+ 'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),
+ ]);
+ return ['movement_id'=>(int)$id,'source_hash'=>$hash,'replayed'=>false];
+ }
+
+ private function checkHost(): void
+ {
+ if($this->db->transactionLevel()<1)throw new \LogicException('Receipt movement requires the host atomic transaction');
+ ($this->authorize)();
+ }
+
+ private function facts(int $lineId,bool $lock): array
+ {
+ $q=$this->db->table($this->schema.'.goods_receipt_items AS l')
+ ->join($this->schema.'.goods_receipts AS r',fn($j)=>$j->on('r.id','=','l.goods_receipt_id')->on('r.tenant_id','=','l.tenant_id'))
+ ->where('l.tenant_id',$this->tenant)->where('l.id',$lineId)
+ ->select(['l.id','l.tenant_id','l.stock_item_id','l.received_milli','l.accepted_milli','l.rejected_milli','l.quality_status','l.notes',
+ 'l.snapshot AS line_snapshot','r.id AS receipt_id','r.purchase_order_id','r.received_by','r.warehouse_id','r.delivery_note','r.received_on','r.confirmed_at','r.snapshot AS receipt_snapshot']);
+ if($lock)$q->lockForUpdate();
+ $row=$q->first();abort_unless($row,403,'Origem fora do âmbito autorizado.');$facts=(array)$row;
+ $snapshot=json_decode($facts['line_snapshot'],true,512,JSON_THROW_ON_ERROR);
+ abort_unless((int)$facts['received_by']===$this->actor,403,'A origem não pertence ao recebedor desta confirmação.');
+ abort_unless($facts['confirmed_at']!==null && ($snapshot['physical']??false)===true && (int)$facts['accepted_milli']>0
+ && $facts['stock_item_id']!==null && $facts['warehouse_id']!==null,409,'A origem não comprova quantidade física aceite.');
+ return $facts;
+ }
+
+ private function hash(array $facts): string
+ {
+ // Parent status/number are intentionally absent: finalisation occurs later in the same transaction.
+ return hash('sha256',json_encode($facts,JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/packages/purchasing/src/Sourcing.php b/packages/purchasing/src/Sourcing.php
new file mode 100644
index 0000000..c8728ac
--- /dev/null
+++ b/packages/purchasing/src/Sourcing.php
@@ -0,0 +1,87 @@
+$q){
+ $evaluations=array_values(array_filter($q['evaluations'],fn($e)=>$e['quote_revision']===$q['revision']&&($e['eligible']??false)));
+ $rows[]=[...$q,'id'=>(int)$id,'eligible'=>($q['supplier_eligible']??false)&&$q['valid_until']>=$today,'weights'=>$policy['weights'],'evaluation_count'=>count($evaluations),'qualified_evaluations'=>$evaluations];
+ }
+ $ranking=(new ProposalScoring)->rank($rows,$policy['minimum_evaluators']);
+ $eligible=array_filter($rows,fn($r)=>$r['eligible']);
+ $ready=count($eligible)>=$policy['minimum_proposals'];
+ foreach($eligible as $row)$ready=$ready&&$row['evaluation_count']>=$policy['minimum_evaluators'];
+ return ['rows'=>$rows,'ready'=>$ready,'top_ids'=>$ranking['top_ids'],'scores'=>$ranking['scores'],'fingerprint'=>hash('sha256',json_encode(['rows'=>$rows,'policy'=>$policy],JSON_THROW_ON_ERROR))];
+ }
+
+ public function transition(array $state,array $request,int $actor,string $action,array $data,array $policy,string $today): array
+ {
+ $fail=fn(string $message,int $status=409)=>throw new RequisitionFailure($message,$status);
+ if($request['status']!=='approved')$fail('A requisição de origem não está aprovada.');
+ if(($state['confirmed']??null)!==null)$fail('Adjudicação confirmada: processo fechado.');
+ if(in_array($action,['evaluate','propose','confirm'],true)&&$actor===$request['requester_id'])$fail('O requerente não pode avaliar, propor ou confirmar esta adjudicação.',403);
+ switch($action){
+ case 'select':
+ $supplier=$data['supplier'];
+ if(!$supplier['eligible'])$fail('Fornecedor não elegível.',422);
+ if(isset($state['suppliers'][$supplier['id']]))$fail('Fornecedor já seleccionado.');
+ $state['suppliers'][$supplier['id']]=$supplier;
+ break;
+ case 'quote':
+ $supplier=(int)$data['supplier_id'];
+ if(!isset($state['suppliers'][$supplier]))$fail('Seleccione um fornecedor autorizado.',422);
+ $old=$state['quotes'][$supplier]??null;$lines=[];$subtotal=0;$tax=0;
+ if(count($data['items'])!==count($request['data']['items']))$fail('A cotação deve cobrir exactamente as linhas da requisição.',422);
+ foreach($request['data']['items'] as $index=>$source){
+ $input=$data['items'][$index]??$fail('Linha em falta.',422);
+ if(($input['line']??null)!==$index)$fail('Referência de linha inválida.',422);
+ $pricing=(new QuotationPricing)->unit($input['unit_price'],$input['discount_type']??'none',$input['discount_value']??'0');
+ $net=BigDecimal::of($source['quantity'])->multipliedBy($pricing['net_unit_minor'])->toScale(0,RoundingMode::HalfUp)->toInt();
+ $vat=BigDecimal::of($net)->multipliedBy($source['tax_rate'])->dividedBy(100,0,RoundingMode::HalfUp)->toInt();
+ $subtotal+=$net;$tax+=$vat;
+ if($subtotal+$tax>100000000000000)$fail('Valor acima do limite técnico.',422);
+ $lines[]=['line'=>$index,'source'=>$source,'pricing'=>$pricing,'subtotal_minor'=>$net,'tax_minor'=>$vat];
+ }
+ if($data['valid_until']<$today)$fail('Cotação expirada.',422);
+ $state['quotes'][$supplier]=['supplier_id'=>$supplier,'revision'=>($old['revision']??0)+1,'reference'=>$data['reference'],'currency'=>'MZN','delivery_days'=>$data['delivery_days'],'payment_terms'=>$data['payment_terms'],'valid_until'=>$data['valid_until'],'author_id'=>$actor,'recorded_on'=>$today,'items'=>$lines,'subtotal_minor'=>$subtotal,'tax_minor'=>$tax,'total_minor'=>$subtotal+$tax,'evaluations'=>[],'supplier_eligible'=>true];
+ break;
+ case 'evaluate':
+ $id=$data['supplier_id'];$q=$state['quotes'][$id]??$fail('Cotação inexistente.',404);
+ if(!($q['supplier_eligible']??false)||$q['valid_until']<$today)$fail('Fornecedor inelegível ou cotação expirada.',422);
+ if($q['revision']!==$data['quote_revision'])$fail('Versão da cotação alterada.');
+ $state['quotes'][$id]['evaluations'][$actor]=['evaluator_id'=>$actor,'quote_revision'=>$q['revision'],'quality'=>$data['quality'],'history'=>$data['history'],'payment'=>$data['payment'],'no_conflict'=>true,'eligible'=>true,'reason'=>$data['reason']];
+ break;
+ case 'propose':
+ $comparison=$this->comparison($state,$policy,$today);
+ if(!$comparison['ready'])$fail('São necessárias '.$policy['minimum_proposals'].' propostas elegíveis e '.$policy['minimum_evaluators'].' avaliações independentes por proposta.',422);
+ if(!hash_equals($comparison['fingerprint'],$data['comparison_hash']))$fail('Comparação desactualizada.');
+ if(!in_array($data['supplier_id'],$comparison['top_ids'],true))$fail('A selecção deve respeitar a classificação da comparação.',422);
+ if(($data['delivery_date']??'')<$today||trim($data['delivery_location']??'')==='')$fail('Indique data e local de entrega previstos, como na adjudicação de Compras.',422);
+ $state['proposals'][]=['supplier_id'=>$data['supplier_id'],'comparison_hash'=>$comparison['fingerprint'],'comparison_version'=>$state['version'],'comparison'=>$comparison,'proposed_by'=>$actor,'reason'=>$data['reason'],'delivery_date'=>$data['delivery_date'],'delivery_location'=>$data['delivery_location'],'proposed_on'=>$today];
+ break;
+ case 'confirm':
+ $proposal=end($state['proposals']);
+ if(!$proposal)$fail('Proposta de adjudicação em falta.',422);
+ if($proposal['proposed_by']===$actor)$fail('A confirmação exige outra identidade.',403);
+ if(($data['proposal_index']??null)!==array_key_last($state['proposals']))$fail('Proposta de adjudicação alterada.');
+ $comparison=$this->comparison($state,$policy,$today);
+ if(!$comparison['ready']||!hash_equals($proposal['comparison_hash'],$comparison['fingerprint'])||!hash_equals($proposal['comparison_hash'],$data['comparison_hash']))$fail('As cotações, avaliações ou elegibilidade alteraram-se; proponha novamente.');
+ $state['confirmed']=['proposal'=>$proposal,'confirmed_by'=>$actor,'reason'=>$data['reason'],'confirmed_on'=>$today];
+ break;
+ default:$fail('Operação não suportada.',404);
+ }
+ if(in_array($action,['quote','evaluate'],true))$state['comparison_changed']=true;
+ return $state;
+ }
+}
diff --git a/phpstan.neon b/phpstan.neon
index 1ca23a4..d2c9d92 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -3,6 +3,8 @@ includes:
- vendor/nesbot/carbon/extension.neon
parameters:
+ scanFiles:
+ - packages/inventory-return/releases/0.1.0/src/DeliveryReturnWriter.php
paths:
- app/
- bootstrap/app.php
diff --git a/resources/js/components/organization-activation-preview.tsx b/resources/js/components/organization-activation-preview.tsx
new file mode 100644
index 0000000..17d2bbf
--- /dev/null
+++ b/resources/js/components/organization-activation-preview.tsx
@@ -0,0 +1,190 @@
+import { Link } from '@inertiajs/react';
+import { Panel } from '@/components/procurement';
+
+type Person = {
+ id: number;
+ name: string;
+ current_permissions: string[];
+ configured_permissions: string[];
+ added: string[];
+ removed: string[];
+ current_can_request: boolean;
+ configured_can_request: boolean;
+ configured_maintenance_only: boolean;
+ permanent_admin: boolean;
+ current_contexts: string[] | null;
+ configured_contexts: string[] | null;
+ loses_access: boolean;
+};
+export type ActivationPreview = {
+ ready: boolean;
+ active?: boolean;
+ version?: number;
+ checked_at?: string;
+ users: Person[];
+ permanent_administrators?: number;
+ provisional_rules?: boolean;
+ can_activate?: boolean;
+ losing_access?: number;
+ context_labels?: Record;
+};
+
+export function OrganizationActivationPreview({
+ preview,
+ permissions,
+ tasks,
+}: {
+ preview: ActivationPreview;
+ permissions: Record;
+ tasks: Record;
+}) {
+ if (!preview.ready) return null;
+ const labels = (keys: string[], source = permissions) =>
+ keys.map((key) => source[key] ?? key).join('; ') || 'Nenhuma';
+ return (
+
+
+
+ {preview.users.length} utilizador(es) ·{' '}
+ {preview.permanent_administrators} administrador(es)
+ permanente(s)
+ {preview.active
+ ? ''
+ : ` · ${preview.losing_access} utilizador(es) ficaria(m) sem competência de acesso`}
+
+ {!preview.active && (
+
+ {preview.can_activate
+ ? 'As condições existentes de dois administradores permanentes e regras confirmadas estão preenchidas. Confira o efeito em cada pessoa antes de activar.'
+ : 'A activação exige dois administradores permanentes atribuídos e regras de aprovação confirmadas.'}
+ {preview.provisional_rules &&
+ ' As regras actuais continuam provisórias.'}
+
+ )}
+
+ As tarefas descritas dependem também do processo,
+ departamento, contexto, independência e competências
+ específicas. Uma competência financeira não substitui os
+ acessos próprios às obrigações financeiras.
+
+
+ Actualizar diagnóstico e configuração
+
+ {preview.users.map((person) => (
+
+
+ {person.name}
+ {person.loses_access && !preview.active
+ ? ' · Ficaria sem acesso'
+ : ''}
+ {person.permanent_admin
+ ? ' · Administração permanente'
+ : ''}
+
+
+
+ Competências actuais: {' '}
+ {labels(person.current_permissions)}
+
+
+
+ Competências dos perfis guardados:
+ {' '}
+ {labels(person.configured_permissions)}
+
+ {!preview.active && (
+ <>
+
+ Acrescentadas: {' '}
+ {labels(person.added)}
+
+
+ Retiradas: {' '}
+ {labels(person.removed)}
+
+ >
+ )}
+
+
+ Preparação de requisição de compra:
+ {' '}
+ actualmente{' '}
+ {person.current_can_request ? 'sim' : 'não'};
+ pelos perfis guardados{' '}
+ {person.configured_can_request ? 'sim' : 'não'}.
+
+ {person.configured_contexts === null ? (
+
+ A selecção de contextos ainda não está
+ activa nesta empresa.
+
+ ) : (
+ <>
+
+ Contextos actuais: {' '}
+ {labels(
+ person.current_contexts ?? [],
+ preview.context_labels,
+ )}
+
+
+
+ Contextos pelos perfis guardados:
+ {' '}
+ {labels(
+ person.configured_contexts,
+ preview.context_labels,
+ )}
+
+ >
+ )}
+ {person.configured_maintenance_only ? (
+
+ Manutenção temporária: diagnóstico e
+ consulta, sem operações de negócio.
+
+ ) : (
+
+
+ Tarefas associadas às competências
+ guardadas:
+
+
+ {person.configured_permissions
+ .flatMap(
+ (permission) =>
+ tasks[permission] ?? [],
+ )
+ .map((task) => (
+
+ {task.label}
+
+ ))}
+ {person.configured_permissions
+ .length === 0 && (
+
+ Sem tarefas atribuídas pelos
+ perfis.
+
+ )}
+
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/resources/js/lib/procurement-navigation.ts b/resources/js/lib/procurement-navigation.ts
index f140b8c..4c00dba 100644
--- a/resources/js/lib/procurement-navigation.ts
+++ b/resources/js/lib/procurement-navigation.ts
@@ -49,6 +49,11 @@ export function procurementNavigation(
label: 'Minhas requisições',
path: '/requisicoes?view=mine',
},
+ {
+ id: 'my_workflow_tasks',
+ label: 'As minhas tarefas',
+ path: '/requisicoes/tarefas',
+ },
{
id: 'my_drafts',
label: 'Os meus rascunhos',
@@ -196,6 +201,18 @@ export function procurementNavigation(
label: 'Pessoas e contratações',
icon: Users,
entries: [
+ {
+ id: 'people_workflow_tasks',
+ label: 'As minhas tarefas',
+ path: '/pessoas/tarefas',
+ roles: [
+ 'admin',
+ 'people_service_prepare',
+ 'people_service_review',
+ 'people_staff_prepare',
+ 'people_staff_review',
+ ],
+ },
{
id: 'people_registry',
label: 'Cadastro reutilizável de pessoas',
@@ -305,6 +322,12 @@ export function procurementNavigation(
'supply_review',
],
},
+ {
+ id: 'warehouse_pilot',
+ label: 'Preparação do piloto',
+ path: '/stock/piloto',
+ roles: ['admin'],
+ },
{
id: 'warehouse_requests',
label: 'Requisições ao armazém',
diff --git a/resources/js/pages/company/parameters.tsx b/resources/js/pages/company/parameters.tsx
index 60cb175..81579d4 100644
--- a/resources/js/pages/company/parameters.tsx
+++ b/resources/js/pages/company/parameters.tsx
@@ -14,6 +14,8 @@ import {
import { Button } from '@/components/ui/button';
import { WorkContextSettings } from '@/components/work-context-settings';
import type { WorkContextState } from '@/types/work-context';
+import { OrganizationActivationPreview } from '@/components/organization-activation-preview';
+import type { ActivationPreview } from '@/components/organization-activation-preview';
type Profile = {
id: number;
@@ -168,6 +170,7 @@ export default function Parameters({
version,
permissions,
workflowTasks = {},
+ activationPreview,
profiles,
memberships,
users,
@@ -197,6 +200,7 @@ export default function Parameters({
version: number;
permissions: { [key: string]: string };
workflowTasks?: Record;
+ activationPreview?: ActivationPreview;
profiles: Profile[];
memberships: Member[];
users: Person[];
@@ -273,6 +277,13 @@ export default function Parameters({
{notice}
)}
+ {activationPreview && (
+
+ )}
{!ready ? (
diff --git a/resources/js/pages/invoices/index.tsx b/resources/js/pages/invoices/index.tsx
index 95a799c..5daf8d5 100644
--- a/resources/js/pages/invoices/index.tsx
+++ b/resources/js/pages/invoices/index.tsx
@@ -1,4 +1,4 @@
-import { Head, router, useForm, usePage } from '@inertiajs/react';
+import { Head, Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
@@ -32,6 +32,7 @@ type Invoice = {
issued_on: string;
status: string;
created_by: number;
+ canValidate: boolean;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
@@ -51,10 +52,14 @@ export default function Invoices({
payablesAvailable = false,
stockReturns = [],
stockReversals = [],
+ focusInvoiceId = null,
+ focusOrderId = null,
}: {
invoices: Page;
orders: { id: number; number: string; supplier: string; items: Source[] }[];
canManage: boolean;
+ focusInvoiceId?: number | null;
+ focusOrderId?: number | null;
payablesAvailable?: boolean;
stockReturns?: {
reference: string;
@@ -72,8 +77,9 @@ export default function Invoices({
event_on: string;
}[];
}) {
- const { auth } = usePage().props;
- const [orderId, setOrderId] = useState('');
+ const [orderId, setOrderId] = useState(
+ focusOrderId === null ? '' : String(focusOrderId),
+ );
const [validationError, setValidationError] = useState('');
const [validating, setValidating] = useState(null);
const form = useForm({
@@ -82,7 +88,15 @@ export default function Invoices({
subtotal: '',
tax: '',
total: '',
- items: [] as Line[],
+ items: (orders
+ .find((source) => source.id === focusOrderId)
+ ?.items.map((line) => ({
+ purchase_order_item_id: line.id,
+ quantity: '',
+ unit: line.unit,
+ unit_price: (line.unit_price_minor / 100).toFixed(2),
+ tax_rate: String(line.tax_rate),
+ })) ?? []) as Line[],
});
const order = orders.find((o) => String(o.id) === orderId);
return (
@@ -148,10 +162,15 @@ export default function Invoices({
)}
- {canManage && (
+ {(focusInvoiceId !== null || focusOrderId !== null) && (
+
+ Todas as facturas / seleccionar outra encomenda
+
+ )}
+ {canManage && focusInvoiceId === null && (
({
...d,
- items: d.items.filter(
- (l) => l.quantity !== '',
- ),
+ items: d.items
+ .filter((l) => l.quantity !== '')
+ .map(
+ ({
+ purchase_order_item_id,
+ quantity,
+ }) => ({
+ purchase_order_item_id,
+ quantity,
+ }),
+ ),
}));
form.post(`/ordens/${orderId}/facturas`, {
onSuccess: () => {
@@ -268,25 +295,31 @@ export default function Invoices({
tax_rate: 'IVA (%)',
}[key]
}
-
- form.setData(
- 'items',
- form.data.items.map(
- (l, i) =>
- i === index
- ? {
- ...l,
- [key]: e
- .target
- .value,
- }
- : l,
- ),
- )
- }
- />
+ {key === 'quantity' ? (
+
+ form.setData(
+ 'items',
+ form.data.items.map(
+ (l, i) =>
+ i === index
+ ? {
+ ...l,
+ [key]: e
+ .target
+ .value,
+ }
+ : l,
+ ),
+ )
+ }
+ />
+ ) : (
+
+ {line[key]}
+
+ )}
))}
@@ -334,45 +367,55 @@ export default function Invoices({
)}
{invoices.data.map((invoice) => (
-
-
- {invoice.snapshot.order_number} ·{' '}
- {invoice.issued_on} ·{' '}
- {invoice.status === 'validated'
- ? 'Validada'
- : 'Pendente de validação'}
-
-
- Subtotal {money(invoice.subtotal_minor)} · IVA{' '}
- {money(invoice.tax_minor)} · Total{' '}
- {money(invoice.total_minor)}
-
- {payablesAvailable && (
-
-
- Consultar ou preparar obrigação desta
- factura
-
+
+
+
+ {invoice.snapshot.order_number} ·{' '}
+ {invoice.issued_on} ·{' '}
+ {invoice.status === 'validated'
+ ? 'Validada'
+ : 'Pendente de validação'}
- )}
-
- {invoice.items.map((l) => (
-
- {l.snapshot.description} ·{' '}
- {l.quantity_milli / 1000} {l.snapshot.unit}{' '}
- · {money(l.subtotal_minor + l.tax_minor)}
-
- ))}
-
- {canManage &&
- invoice.status === 'pending' &&
- invoice.created_by !== auth.user?.id && (
+
+ Subtotal {money(invoice.subtotal_minor)} · IVA{' '}
+ {money(invoice.tax_minor)} · Total{' '}
+ {money(invoice.total_minor)}
+
+ {payablesAvailable && (
+
+
+ Consultar ou preparar obrigação desta
+ factura
+
+
+ )}
+ {invoice.status === 'validated' && (
+
+
+ Consultar pagamentos externos desta
+ factura
+
+
+ )}
+
+ {invoice.items.map((l) => (
+
+ {l.snapshot.description} ·{' '}
+ {l.quantity_milli / 1000}{' '}
+ {l.snapshot.unit} ·{' '}
+ {money(l.subtotal_minor + l.tax_minor)}
+
+ ))}
+
+ {invoice.canValidate && (
{
@@ -397,7 +440,8 @@ export default function Invoices({
Conferir e validar
)}
-
+
+
))}
{invoices.data.length === 0 && (
Sem facturas registadas nesta empresa.
diff --git a/resources/js/pages/payments/index.tsx b/resources/js/pages/payments/index.tsx
index d884b5c..b0d8604 100644
--- a/resources/js/pages/payments/index.tsx
+++ b/resources/js/pages/payments/index.tsx
@@ -1,4 +1,4 @@
-import { Head, Link, useForm, usePage } from '@inertiajs/react';
+import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
@@ -24,6 +24,7 @@ type Invoice = {
bank_state: string;
};
type Payment = {
+ canReview: boolean;
id: number;
invoice_id: number;
created_by: number;
@@ -90,15 +91,21 @@ export default function Payments({
invoices,
payments,
canManage,
+ focusPaymentId = null,
+ focusInvoiceId = null,
}: {
invoices: Invoice[];
payments: Page;
canManage: boolean;
+ focusPaymentId?: number | null;
+ focusInvoiceId?: number | null;
}) {
- const { auth } = usePage().props;
- const [invoiceId, setInvoiceId] = useState('');
+ const [invoiceId, setInvoiceId] = useState(
+ focusInvoiceId === null ? '' : String(focusInvoiceId),
+ );
const form = useForm({
- supplier_bank_detail_id: 0,
+ supplier_bank_detail_id:
+ invoices.find((i) => i.id === focusInvoiceId)?.bank_id ?? 0,
amount: '',
paid_on: '',
external_reference: '',
@@ -157,7 +164,15 @@ export default function Payments({
)}
- {canManage && (
+ {(focusPaymentId !== null || focusInvoiceId !== null) && (
+
+ Ver todos os registos externos
+
+ )}
+ {canManage && focusPaymentId === null && (
)}
{payments.data.map((p) => (
- i.id === p.invoice_id)?.number ?? `Factura #${p.invoice_id}`}`}
- >
-
- {money(p.amount_minor)} · {p.paid_on} ·{' '}
- {{
- pending: 'Pendente de conferência',
- confirmed: 'Confirmado',
- rejected: 'Conferência rejeitada',
- }[p.status] ?? p.status}
-
- {p.external_reference && (
-
- Referência: {p.external_reference}
+
+
i.id === p.invoice_id)?.number ?? `Factura #${p.invoice_id}`}`}
+ >
+
+ {money(p.amount_minor)} · {p.paid_on} ·{' '}
+ {{
+ pending: 'Pendente de conferência',
+ confirmed: 'Confirmado',
+ rejected: 'Conferência rejeitada',
+ }[p.status] ?? p.status}
- )}
- {p.evidence_reference && (
-
- Evidência: {p.evidence_reference}
-
- )}
- {canManage &&
- p.status === 'pending' &&
- p.created_by !== auth.user?.id && (
-
+ {p.external_reference && (
+
+ Referência: {p.external_reference}
+
)}
-
+ {p.evidence_reference && (
+
+ Evidência: {p.evidence_reference}
+
+ )}
+ {p.canReview &&
}
+
+
))}
diff --git a/resources/js/pages/purchases/admission.tsx b/resources/js/pages/purchases/admission.tsx
index e8b1cda..5b3fbed 100644
--- a/resources/js/pages/purchases/admission.tsx
+++ b/resources/js/pages/purchases/admission.tsx
@@ -44,6 +44,7 @@ type Decision = {
reviews: Review[];
};
type Props = {
+ focusDecisionId?: number | null;
requisition: { id: number; number: string };
policy: { id: number; revision: number; enabled: boolean } | null;
adoption: Adoption | null;
@@ -245,6 +246,7 @@ export default function Admission({
canAdopt,
canPropose,
closed,
+ focusDecisionId = null,
}: Props) {
const base = `/requisicoes/${requisition.id}`;
return (
@@ -281,7 +283,7 @@ export default function Admission({
O processo está fechado para alterações de admissão.
)}
- {canAdopt && (
+ {focusDecisionId === null && canAdopt && (
)}
- {canPropose && (
+ {focusDecisionId === null && canPropose && (
{proposals.map((p) => (
@@ -339,12 +341,21 @@ export default function Admission({
)}
+ {focusDecisionId !== null && (
+
+ Ver todas as tentativas desta versão
+
+ )}
{history.data.length === 0 && (
Ainda não há tentativas nesta adopção.
)}
{history.data.map((d) => (
diff --git a/resources/js/pages/purchases/qualification.tsx b/resources/js/pages/purchases/qualification.tsx
index 41a17b2..6be8639 100644
--- a/resources/js/pages/purchases/qualification.tsx
+++ b/resources/js/pages/purchases/qualification.tsx
@@ -65,6 +65,7 @@ type Props = {
canDefine: boolean;
canAssess: boolean;
closedForChanges: boolean;
+ focusQuotationId?: number | null;
};
const categories: Record = {
legal: 'Jurídica',
@@ -257,10 +258,12 @@ function AssessForm({
selected,
records,
actorId,
+ focusQuotationId,
}: {
selected: Set;
records: Assessment[];
actorId: number;
+ focusQuotationId: number | null;
}) {
const blank = (): Answer[] =>
selected.criteria.map((criterion) => ({
@@ -269,11 +272,18 @@ function AssessForm({
evidence: '',
reason: '',
}));
+ const initial = records.find(
+ (record) =>
+ record.quotation_id === focusQuotationId &&
+ record.analyst_id === actorId,
+ );
const form = useForm({
- previous_id: 0,
- quotation_id: '',
+ previous_id: initial?.id ?? 0,
+ quotation_id: focusQuotationId === null ? '' : String(focusQuotationId),
reason: '',
- answers: blank(),
+ answers: initial
+ ? initial.answers.map((answer) => ({ ...answer }))
+ : blank(),
});
const choose = (id: string) => {
const current = records.find(
@@ -532,6 +542,7 @@ export default function QualificationPage(props: Props) {
canDefine,
canAssess,
closedForChanges,
+ focusQuotationId = null,
} = props;
const { errors } = usePage().props;
const base = `/requisicoes/${requisition.id}/qualificacao`;
@@ -656,17 +667,20 @@ export default function QualificationPage(props: Props) {
)}
{canAssess && (
-
-
-
+
+
+ record.quotation_id === focusQuotationId && record.analyst_id === actorId)?.id ?? 0)}`}
+ selected={selected}
+ records={records}
+ actorId={actorId}
+ focusQuotationId={focusQuotationId}
+ />
+
+
)}
)}
- {canAdopt && (
+ {focusAttemptId === null && canAdopt && (
)}
- {canPropose && (
+ {focusAttemptId === null && canPropose && (
)}
+ {focusAttemptId !== null && (
+
+ Ver todas as tentativas desta versão
+
+ )}
{history.data.length === 0 && (
Sem tentativas nesta adopção.
)}
{history.data.map((attempt) => (
diff --git a/resources/js/pages/quotations/show.tsx b/resources/js/pages/quotations/show.tsx
index bd9ab29..bf036ac 100644
--- a/resources/js/pages/quotations/show.tsx
+++ b/resources/js/pages/quotations/show.tsx
@@ -1,4 +1,4 @@
-import { Head, Link, useForm, usePage } from '@inertiajs/react';
+import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
@@ -33,6 +33,7 @@ type Evaluation = {
created_at: string;
};
type Quote = {
+ canEvaluate: boolean;
id: number;
reference: string;
supplier: { name: string };
@@ -56,7 +57,8 @@ export default function Comparison({
quotations,
suppliers,
canCreate,
- canEvaluate,
+ focusQuotationId = null,
+ prepare = false,
ready,
topIds,
comparisonPolicy,
@@ -71,13 +73,14 @@ export default function Comparison({
suppliers: { id: number; name: string }[];
canCreate: boolean;
canEvaluate: boolean;
+ focusQuotationId?: number | null;
+ prepare?: boolean;
ready: boolean;
topIds: number[];
comparisonPolicy: ComparisonPolicy;
}) {
- const { auth } = usePage().props;
- const [adding, setAdding] = useState(false);
- const [selected, setSelected] = useState(null);
+ const [adding, setAdding] = useState(prepare && canCreate);
+ const [selected, setSelected] = useState(focusQuotationId);
const form = useForm({
supplier_id: '',
reference: '',
@@ -188,265 +191,273 @@ export default function Comparison({
selecção deve ficar fundamentada na adjudicação.
)}
- {adding && (
-
- {
- e.preventDefault();
- form.post(
- `/requisicoes/${requisition.id}/cotacoes`,
- {
- onSuccess: () => {
- form.reset();
- setAdding(false);
- },
- },
- );
- }}
+ {canCreate && adding && (
+
+
-
-
-
-
- Fornecedor activo
-
-
- form.setData(
- 'supplier_id',
- e.target.value,
- )
- }
- >
- Seleccione
- {suppliers.map((s) => (
-
- {s.name}
-
- ))}
-
-
- {(
- [
- [
- 'reference',
- 'Referência do fornecedor',
- 'text',
- ],
- [
- 'delivery_days',
- 'Prazo de entrega (dias)',
- 'number',
- ],
- ['valid_until', 'Válida até', 'date'],
- [
- 'payment_terms',
- 'Condições de pagamento',
- 'text',
- ],
- ] as const
- ).map(([key, label, type]) => (
-
-
{label}
-
{
+ e.preventDefault();
+ form.post(
+ `/requisicoes/${requisition.id}/cotacoes`,
+ {
+ onSuccess: () => {
+ form.reset();
+ setAdding(false);
+ },
+ },
+ );
+ }}
+ >
+
+
+
+
+ Fornecedor activo
+
+
form.setData(
- key,
+ 'supplier_id',
e.target.value,
)
}
- />
+ >
+ Seleccione
+ {suppliers.map((s) => (
+
+ {s.name}
+
+ ))}
+
- ))}
-
-
- {requisition.items.map((i, index) => (
-
-
-
+ {(
+ [
+ [
+ 'reference',
+ 'Referência do fornecedor',
+ 'text',
+ ],
+ [
+ 'delivery_days',
+ 'Prazo de entrega (dias)',
+ 'number',
+ ],
+ [
+ 'valid_until',
+ 'Válida até',
+ 'date',
+ ],
+ [
+ 'payment_terms',
+ 'Condições de pagamento',
+ 'text',
+ ],
+ ] as const
+ ).map(([key, label, type]) => (
+
+ {label}
+
+ form.setData(
+ key,
+ e.target.value,
+ )
+ }
+ />
+
+ ))}
+
+
+ {requisition.items.map((i, index) => (
+
+
+
+ {i.description}
+
+
+ {i.quantity} unidades · IVA{' '}
+ {i.tax_rate}%
+
+
+
+
+ Preço unitário antes de
+ desconto e IVA (MZN)
+
+
+ form.setData(
+ 'items',
+ form.data.items.map(
+ (line, k) =>
+ k === index
+ ? {
+ ...line,
+ unit_price:
+ e
+ .target
+ .value,
+ }
+ : line,
+ ),
+ )
+ }
+ />
+
+
+ ))}
+
+
+
+ Descontos unitários antes do IVA
+
+ {requisition.items.map((i, index) => (
+
+
{i.description}
-
-
- {i.quantity} unidades · IVA{' '}
- {i.tax_rate}%
-
+
+
+ Tipo de desconto
+
+ form.setData(
+ 'items',
+ form.data.items.map(
+ (line, k) =>
+ k === index
+ ? {
+ ...line,
+ discount_type:
+ e
+ .target
+ .value,
+ discount_value:
+ '0',
+ }
+ : line,
+ ),
+ )
+ }
+ >
+
+ Sem desconto
+
+
+ Percentagem (%)
+
+
+ Valor por unidade (MZN)
+
+
+
+
+ {form.data.items[index]
+ .discount_type === 'percent'
+ ? 'Percentagem (%)'
+ : 'Valor unitário (MZN)'}
+
+ form.setData(
+ 'items',
+ form.data.items.map(
+ (line, k) =>
+ k === index
+ ? {
+ ...line,
+ discount_value:
+ e
+ .target
+ .value,
+ }
+ : line,
+ ),
+ )
+ }
+ />
+
-
-
- Preço unitário antes de desconto
- e IVA (MZN)
-
-
- form.setData(
- 'items',
- form.data.items.map(
- (line, k) =>
- k === index
- ? {
- ...line,
- unit_price:
- e
- .target
- .value,
- }
- : line,
- ),
- )
- }
- />
-
-
- ))}
-
-
-
- Descontos unitários antes do IVA
-
- {requisition.items.map((i, index) => (
-
-
- {i.description}
-
-
- Tipo de desconto
-
- form.setData(
- 'items',
- form.data.items.map(
- (line, k) =>
- k === index
- ? {
- ...line,
- discount_type:
- e
- .target
- .value,
- discount_value:
- '0',
- }
- : line,
- ),
- )
- }
- >
-
- Sem desconto
-
-
- Percentagem (%)
-
-
- Valor por unidade (MZN)
-
-
-
-
- {form.data.items[index]
- .discount_type === 'percent'
- ? 'Percentagem (%)'
- : 'Valor unitário (MZN)'}
-
- form.setData(
- 'items',
- form.data.items.map(
- (line, k) =>
- k === index
- ? {
- ...line,
- discount_value:
- e
- .target
- .value,
- }
- : line,
- ),
- )
- }
- />
-
-
- ))}
-
-
-
- form.setData(
- 'no_conflict',
- e.target.checked,
- )
- }
- />
- Declaro que não tenho conflito de interesses com
- este fornecedor.
-
-
- Guardar proposta
-
-
-
+ ))}
+
+
+
+ form.setData(
+ 'no_conflict',
+ e.target.checked,
+ )
+ }
+ />
+ Declaro que não tenho conflito de interesses
+ com este fornecedor.
+
+
+ Guardar proposta
+
+
+
+
)}
q.id === selected)
.map((q) => (
-
- {q.ineligibility_reasons.length > 0 && (
-
- {q.ineligibility_reasons.map((reason) => (
- {reason}
- ))}
-
- )}
-
-
- {q.items.map((i) => (
-
- {i.description} · {i.quantity} ×{' '}
- {money(i.unit_price_minor)} · total com
- IVA {money(i.total_minor)}
-
- ))}
-
-
- Avaliações registadas
-
- {q.evaluations.map((ev) => (
-
-
- Avaliador #{ev.evaluator_id}
-
-
- Registada em{' '}
- {new Date(ev.created_at).toLocaleString(
- 'pt-MZ',
+
+
+ {q.ineligibility_reasons.length > 0 && (
+
+ )}
+
+
+ {q.items.map((i) => (
+
+ {i.description} · {i.quantity} ×{' '}
+ {money(i.unit_price_minor)} · total
+ com IVA {money(i.total_minor)}
+
+ ))}
- ))}
- {canEvaluate &&
- q.eligible &&
- !q.conflict_blocked &&
- !q.evaluations.some(
- (ev) => ev.evaluator_id === auth.user.id,
- ) && (
+
+ Avaliações registadas
+
+ {q.evaluations.map((ev) => (
+
+
+ Avaliador #{ev.evaluator_id}
+
+
+ Registada em{' '}
+ {new Date(
+ ev.created_at,
+ ).toLocaleString('pt-MZ')}
+
+
+ Qualidade {ev.quality} · histórico{' '}
+ {ev.history} · pagamento{' '}
+ {ev.payment}
+
+
+ {ev.comment}
+
+
+ ))}
+ {q.canEvaluate && (
{
@@ -701,7 +710,8 @@ export default function Comparison({
)}
-
+
+
))}
>
diff --git a/resources/js/pages/requisitions/show.tsx b/resources/js/pages/requisitions/show.tsx
index 00e107e..a6aeb3e 100644
--- a/resources/js/pages/requisitions/show.tsx
+++ b/resources/js/pages/requisitions/show.tsx
@@ -19,9 +19,12 @@ type Audit = {
action: string;
user_id: number;
created_at: string;
+ execution?: string | null;
};
const actions: Record = {
'requisition.created': 'Rascunho criado',
+ 'requisition.automatically_created':
+ 'Rascunho de reposição criado automaticamente',
'requisition.updated': 'Rascunho actualizado',
'requisition.submitted': 'Pedido submetido',
'approval.approved': 'Etapa aprovada',
@@ -37,6 +40,7 @@ export default function Show({
technical,
comparisonPolicy,
replenishmentSource,
+ automaticSource,
approvalRouting,
canRefreshRouting = false,
}: {
@@ -60,6 +64,18 @@ export default function Show({
origin: string;
} | null;
canEdit: boolean;
+ automaticSource?: {
+ need_id: number;
+ rule_id: number;
+ created_at: string;
+ origins: {
+ warehouse_request_id: number;
+ href: string | null;
+ destination: string;
+ equipment: { code: string; description: string } | null;
+ remaining_milli: number;
+ }[];
+ } | null;
canDecide: boolean;
conflictBlocked: boolean;
audit: Audit[];
@@ -122,6 +138,37 @@ export default function Show({
}
/>
+ {automaticSource && (
+
+
+ Preparada automaticamente em{' '}
+ {automaticSource.created_at}, pela parametrização #
+ {automaticSource.rule_id}. Necessidade #
+ {automaticSource.need_id}. O requisitante confere e
+ submete às aprovações habituais.
+
+ {automaticSource.origins.map((o, i) => (
+
+ {o.href ? (
+
+ Requisição ao armazém RA-
+ {o.warehouse_request_id}
+
+ ) : (
+ <>
+ Requisição ao armazém RA-
+ {o.warehouse_request_id}
+ >
+ )}{' '}
+ · Utilização: {o.destination}
+ {o.equipment
+ ? ` · Equipamento ${o.equipment.code} · ${o.equipment.description}`
+ : ''}{' '}
+ · Saldo na origem: {o.remaining_milli / 1000}
+
+ ))}
+
+ )}
{replenishmentSource && (
@@ -648,7 +695,11 @@ export default function Show({
{actions[a.action] ?? a.action}
- {date(a.created_at)} · Utilizador #
+ {date(a.created_at)} ·{' '}
+ {a.execution ===
+ 'scheduled_replenishment'
+ ? 'Execução periódica · Responsável de origem #'
+ : 'Utilizador #'}
{a.user_id}
diff --git a/resources/js/pages/stock/availability.tsx b/resources/js/pages/stock/availability.tsx
index 9cbf2d1..c83bdca 100644
--- a/resources/js/pages/stock/availability.tsx
+++ b/resources/js/pages/stock/availability.tsx
@@ -183,14 +183,19 @@ export default function Availability(props: Props) {
{!row.reconciled
? 'Reconciliar repartição dos lotes'
- : row.usable_milli === 0
- ? 'Sem material utilizável — necessidade de compra'
- : row.at_minimum
- ? 'No mínimo ou abaixo — necessidade de compra'
- : row.minimum_milli ===
- null
- ? 'Mínimo por determinar'
- : 'Acima do mínimo'}
+ : row.free_milli !==
+ null &&
+ row.free_milli < 0
+ ? 'Reserva com défice de material utilizável — rever qualidade, validade e atendimento'
+ : row.usable_milli ===
+ 0
+ ? 'Sem material utilizável — necessidade de compra'
+ : row.at_minimum
+ ? 'No mínimo ou abaixo — necessidade de compra'
+ : row.minimum_milli ===
+ null
+ ? 'Mínimo por determinar'
+ : 'Acima do mínimo'}
))}
diff --git a/resources/js/pages/stock/catalogue.tsx b/resources/js/pages/stock/catalogue.tsx
index 6f76201..a61120d 100644
--- a/resources/js/pages/stock/catalogue.tsx
+++ b/resources/js/pages/stock/catalogue.tsx
@@ -62,6 +62,9 @@ function EntryForm({ entry }: { entry: Entry }) {
>
Insumos / matéria-prima
Outro bem
+
+ Peça registada para equipamento
+
diff --git a/resources/js/pages/stock/operations.tsx b/resources/js/pages/stock/operations.tsx
index f6ea6c1..4528b76 100644
--- a/resources/js/pages/stock/operations.tsx
+++ b/resources/js/pages/stock/operations.tsx
@@ -1,4 +1,4 @@
-import { Head, useForm } from '@inertiajs/react';
+import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
import { LotCountFields, countTotal } from '@/components/inventory-lot-count';
@@ -92,6 +92,7 @@ type Props = {
operations: Page;
counts: Operation[];
canOperate: boolean;
+ canViewReturns?: boolean;
correctedOperations?: Record;
canAdjust?: boolean;
lotCounts?: LotCountSource[];
@@ -112,6 +113,7 @@ export default function Operations({
operations,
counts,
canOperate,
+ canViewReturns = false,
correctedOperations = {},
canAdjust = false,
lotCounts = [],
@@ -171,6 +173,7 @@ export default function Operations({
description="Movimentos com fundamento, contagens físicas e histórico preservado."
/>
+ {canViewReturns && Conferir devoluções de excedentes}
{canOperate && form.data.kind === 'issue' && (
Equipamento de destino (obrigatório para peças
@@ -534,6 +537,7 @@ export default function Operations({
'Devolução ao fornecedor',
write_off: 'Abate físico',
reversal: 'Estorno integral',
+ workshop_return: 'Devolução de excedente',
}[o.kind] ??
o.kind}
diff --git a/resources/js/pages/stock/pilot.tsx b/resources/js/pages/stock/pilot.tsx
new file mode 100644
index 0000000..477a8ef
--- /dev/null
+++ b/resources/js/pages/stock/pilot.tsx
@@ -0,0 +1,223 @@
+import { Head, Link } from '@inertiajs/react';
+import { PageTitle, Panel } from '@/components/procurement';
+
+type Person = {
+ id: number;
+ name: string;
+ department_id: number | null;
+ has_cost_center?: boolean;
+};
+type Props = {
+ syntheticEvaluation: boolean;
+ report: {
+ checked_at: string;
+ warehouse_scenario_ready: boolean;
+ replenishment_scenario_ready: boolean;
+ client_configuration_complete: boolean;
+ checks: {
+ key: string;
+ label: string;
+ ok: boolean;
+ detail: string;
+ path: string | null;
+ }[];
+ participants: Record;
+ registered: Person[];
+ runtime: {
+ scanMinutes: number;
+ schedulerRecent: boolean;
+ schedulerStatus: { finished_at: string; result: string } | null;
+ };
+ };
+};
+const tasks: Record = {
+ prepare: 'Preparar e confirmar requisições ao armazém',
+ review: 'Autorizar e acompanhar reservas',
+ deliver: 'Entregar pelo armazém (com independência)',
+ purchase_requester: 'Requisitante de compra',
+ supply_prepare: 'Preparar políticas e reposição',
+ supply_review: 'Conferir políticas e activar reposição',
+};
+
+export default function Pilot({ report: r, syntheticEvaluation }: Props) {
+ return (
+ <>
+
+
+
+
+ {syntheticEvaluation
+ ? 'Avaliação de desenvolvimento: usar contas e dados sintéticos.'
+ : 'Diagnóstico da configuração desta empresa.'}{' '}
+ O resultado indica condições observadas, sem certificar
+ aceitação comercial, revisão visual completa ou cobertura
+ transferida para o SGF.
+
+
+ Exportar conferências (CSV)
+ window.print()}
+ >
+ Imprimir diagnóstico
+
+
+ Acompanhamento de requisições
+
+ Reposição automática
+
+ Parametrização da aplicação
+
+
+
+ {[
+ ['Percurso de armazém', r.warehouse_scenario_ready],
+ ['Reposição periódica', r.replenishment_scenario_ready],
+ [
+ 'Configuração de cliente',
+ r.client_configuration_complete,
+ ],
+ ].map(([label, ok]) => (
+
+
{label}
+
+ {ok
+ ? 'Condições básicas observadas'
+ : 'Há preparação por concluir'}
+
+
+ ))}
+
+
+
+ A exportação regista a situação observada na data
+ indicada. Não activa configurações nem substitui o
+ ensaio dos percursos. Os dados podem mudar entre a
+ consulta e uma nova exportação.
+
+
+ Conferido em{' '}
+ {new Date(r.checked_at).toLocaleString('pt-PT')}. Não
+ são presumidos valores, intervenientes ou aprovações em
+ falta.
+
+ {r.checks.map((c) => (
+
+
+ {c.ok ? '✓ Conferido' : 'A preparar'} ·{' '}
+ {c.label}
+
+
{c.detail}
+ {c.path && (
+
+ Consultar / configurar
+
+ )}
+
+ ))}
+
+
+
+ Elegibilidade conforme as competências existentes,
+ contexto e departamento. Ter uma conta ou título de
+ função não concede as tarefas específicas deste
+ percurso. É necessário requisitante e autorizador do
+ mesmo departamento, centro de custo compatível e
+ entregador distinto de ambos.
+
+
+ {Object.entries(tasks).map(([key, label]) => (
+
+
{label}
+ {r.participants[key]?.length ? (
+
+ {r.participants[key].map((p) => (
+
+ {p.name}
+ {p.department_id !== null
+ ? ` · departamento #${p.department_id}`
+ : ''}
+ {(key === 'prepare' ||
+ key ===
+ 'purchase_requester') &&
+ !p.has_cost_center
+ ? ' · falta centro de custo no departamento'
+ : ''}
+
+ ))}
+
+ ) : (
+
+ Nenhuma conta elegível nesta tarefa.
+ Definir o perfil e a associação na
+ parametrização.
+
+ )}
+
+ ))}
+
+
+
+
+
+ Consultar {r.registered.length} conta(s), sem criar
+ novas identidades
+
+
+ {r.registered.map((p) => (
+
+ {p.name} · #{p.id}
+
+ ))}
+
+
+
+
+
+
+ Conferir tarefas, centro de custo, artigo publicado
+ e armazém usando contas existentes.
+
+
+ Preparar e submeter uma requisição com local de
+ utilização e equipamento quando aplicável.
+
+
+ Autorizar com outra conta elegível, conferir reserva
+ e entregar com uma terceira conta independente.
+
+
+ Confirmar a recepção pelo requisitante; conferir
+ saldo e ausência de segunda saída.
+
+
+ Ensaiar entrega parcial, cancelamento apenas do
+ restante e material indisponível por
+ qualidade/validade.
+
+
+ Na reposição configurada, conferir um único rascunho
+ e manutenção das aprovações; testar recuperação em
+ base descartável.
+
+
+
+ Preparar ou diagnosticar não executa estes passos. A
+ preparação e a activação de reposição exigem
+ competências e independência próprias. O SGF real e a
+ proposta de identidade adicional mantêm o seu âmbito
+ separado.
+
+
+
+ >
+ );
+}
diff --git a/resources/js/pages/stock/replenishment.tsx b/resources/js/pages/stock/replenishment.tsx
index 31e7bce..072cc92 100644
--- a/resources/js/pages/stock/replenishment.tsx
+++ b/resources/js/pages/stock/replenishment.tsx
@@ -20,6 +20,7 @@ type RecordRow = {
unit_price?: string;
tax_rate?: string;
needed_days?: number;
+ equipment_id?: number;
valid_until?: string;
message?: string;
purchases?: { id: number; number: string; quantity_milli: number }[];
@@ -37,13 +38,66 @@ type RecordRow = {
}[];
};
};
+type Page = {
+ data: T[];
+ total: number;
+ from: number | null;
+ to: number | null;
+ prev_page_url: string | null;
+ next_page_url: string | null;
+};
+function Pagination({
+ page,
+ label,
+}: {
+ page: Page | null;
+ label: string;
+}) {
+ return page ? (
+
+
+ {page.from ?? 0}–{page.to ?? 0} de {page.total}
+
+ {page.prev_page_url && (
+
+ Anterior
+
+ )}
+ {page.next_page_url && (
+
+ Seguinte
+
+ )}
+
+ ) : null;
+}
type Props = {
ready: boolean;
canPrepare: boolean;
canReview: boolean;
- rules: RecordRow[];
- needs: RecordRow[];
- history: RecordRow[];
+ rules: Page | null;
+ needs: Page | null;
+ history: Page | null;
+ filters: { need_state: string };
+ recoveryReady: boolean;
+ workItems: Page<{
+ id: number;
+ code: string;
+ name: string;
+ warehouse: string;
+ last_error: string | null;
+ due_at: string;
+ failures: number;
+ }> | null;
+ scanMinutes: number;
+ schedulerStatus: { finished_at: string; result: string } | null;
+ schedulerRecent: boolean;
+ work: {
+ total: number;
+ pending: number | null;
+ failed: number | null;
+ last_run_at: string | null;
+ } | null;
policies: RecordRow[];
requesters: { id: number; name: string; department_id: number }[];
centers: {
@@ -52,6 +106,13 @@ type Props = {
name: string;
department_id: number;
}[];
+ equipment: {
+ id: number;
+ code: string;
+ description: string;
+ department_id: number;
+ }[];
+ catalogueTypes: Record;
};
const states: Record = {
draft: 'Por activar',
@@ -76,6 +137,7 @@ function RuleForm({ props, rule }: { props: Props; rule?: RecordRow }) {
unit_price: rule?.data.unit_price ?? '',
tax_rate: rule?.data.tax_rate ?? '',
needed_days: rule?.data.needed_days?.toString() ?? '',
+ equipment_id: rule?.data.equipment_id?.toString() ?? '',
valid_until: rule?.data.valid_until ?? '',
},
});
@@ -86,7 +148,10 @@ function RuleForm({ props, rule }: { props: Props; rule?: RecordRow }) {
form.setData('data', {
...form.data.data,
[key]: value,
- ...(key === 'requester_id' ? { cost_center_id: '' } : {}),
+ ...(key === 'requester_id'
+ ? { cost_center_id: '', equipment_id: '' }
+ : {}),
+ ...(key === 'policy_key' ? { equipment_id: '' } : {}),
});
return (
+ {props.catalogueTypes[
+ props.policies.find(
+ (p) => p.record_key === form.data.data.policy_key,
+ )?.snapshot.item.id ?? 0
+ ] === 'spare_part' && (
+
+ Equipamento de destino da peça
+ set('equipment_id', e.target.value)}
+ >
+
+ Seleccione um equipamento compatível
+
+ {props.equipment
+ .filter(
+ (e) =>
+ e.department_id ===
+ requester?.department_id,
+ )
+ .map((e) => (
+
+ {e.code} · {e.description}
+
+ ))}
+
+
+ )}
Preço estimado (MZN), se não existir no acordo
)}
+
+
+ {props.recoveryReady
+ ? 'Recuperação durável instalada. A verificação periódica depende do serviço de agendamento em execução.'
+ : 'Instalação da recuperação durável pendente.'}
+
+ {props.recoveryReady && (
+
+ Intervalo de verificação por artigo/armazém:{' '}
+ {props.scanMinutes} minutos. Serviço:{' '}
+ {props.schedulerStatus
+ ? `${!props.schedulerRecent ? 'sem passagem recente confirmada; último registo' : props.schedulerStatus.result === 'passed' ? 'última passagem concluída' : 'recuperação pendente'} em ${props.schedulerStatus.finished_at}`
+ : 'sem confirmação recente nesta interface'}
+ .
+
+ )}
+ {props.work && (
+
+ Trabalhos acompanhados: {props.work.total} ·
+ Sinais pendentes: {props.work.pending ?? 0}{' '}
+ · A recuperar: {props.work.failed ?? 0} ·
+ Última verificação:{' '}
+ {props.work.last_run_at ??
+ 'Ainda sem execução registada'}
+
+ )}
+
+ {props.workItems && props.workItems.total > 0 && (
+
+ {props.workItems.data.map((w) => (
+
+
+ {w.code} · {w.name} · {w.warehouse}
+
+
+ {w.last_error ??
+ 'Alteração registada; avaliação pendente.'}{' '}
+ Próxima tentativa: {w.due_at}
+
+ {(props.canPrepare ||
+ props.canReview) && (
+
+ router.post(
+ `/stock/reposicao/trabalhos/${w.id}/avaliar`,
+ {},
+ {
+ preserveState:
+ 'errors',
+ },
+ )
+ }
+ >
+ Reavaliar agora
+
+ )}
+
+ ))}
+
+
+ )}
- {props.rules.length === 0 ? (
+ {(props.rules?.data.length ?? 0) === 0 ? (
Sem parametrizações. Não são assumidos
requisitantes ou centros de custo.
) : (
- props.rules.map((r) => (
+ props.rules?.data.map((r) => (
))
)}
+
- {props.needs.length === 0 ? (
+
+ Estado
+
+ router.get(
+ '/stock/reposicao',
+ { need_state: e.target.value },
+ { preserveScroll: true },
+ )
+ }
+ >
+
+ Todos · pendências primeiro
+
+ {[
+ 'pending',
+ 'created',
+ 'covered',
+ 'satisfied',
+ ].map((s) => (
+
+ {states[s]}
+
+ ))}
+
+
+ {(props.needs?.data.length ?? 0) === 0 ? (
Sem necessidades registadas após alterações
de stock ou activação de regras.
) : (
- props.needs.map((n) => (
+ props.needs?.data.map((n) => (
- Artigo #{n.snapshot.item.id} ·
- Armazém #{n.snapshot.warehouse.id} ·{' '}
+ {n.snapshot.item.code} ·{' '}
+ {n.snapshot.item.name} ·
+ {n.snapshot.warehouse.name} ·{' '}
{states[n.state]}
{n.data.message}
@@ -412,19 +608,27 @@ export default function Replenishment(props: Props) {
))
)}
+
Consultar versões e fundamentos
- {props.history.map((h) => (
+ {props.history?.data.map((h) => (
#{h.id} · {h.created_at} · Versão{' '}
{h.version} · {states[h.state]} ·{' '}
{h.reason}
))}
+
>
diff --git a/resources/js/pages/stock/request.tsx b/resources/js/pages/stock/request.tsx
index 48b80e5..79e4121 100644
--- a/resources/js/pages/stock/request.tsx
+++ b/resources/js/pages/stock/request.tsx
@@ -6,6 +6,7 @@ import {
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
+import type { WarehouseHealth } from '@/types/inventory';
type Line = {
item: { code: string; name: string; unit: string };
quantity_milli: number;
@@ -30,6 +31,45 @@ type Event = {
};
};
type Props = {
+ canViewReturns?: boolean;
+ supplyTrace: {
+ ready: boolean;
+ groups: {
+ item: { id: number; code: string; name: string; unit: string };
+ line_indexes: number[];
+ origin_need_id: number;
+ current_need_id: number;
+ need_state: string;
+ message: string;
+ unreserved_milli: number;
+ restricted: boolean;
+ purchases: {
+ id: number;
+ number: string;
+ status: string;
+ href: string;
+ next_task: string | null;
+ orders: {
+ id: number;
+ number: string;
+ href: string | null;
+ effective_received_milli: number;
+ receipts: {
+ id: number;
+ item_id: number;
+ received_on: string;
+ accepted_milli: number;
+ reversed: boolean;
+ }[];
+ }[];
+ }[];
+ }[];
+ };
+ health: WarehouseHealth;
+ people: Record<
+ 'requester' | 'review' | 'delivery',
+ { id: number; name: string }[]
+ >;
record: {
id: number;
snapshot: {
@@ -99,7 +139,10 @@ function Command({
.filter((line) => line.remaining > 0),
});
return (
-
+
{labels[action]}
@@ -163,6 +206,8 @@ function Command({
.code
}{' '}
· Pendente {line.remaining / 1000}{' '}
+ {props.progress.reservation_ready &&
+ `· Reservado ${props.progress.reserved[line.index] / 1000} `}
{
props.record.snapshot.lines[line.index].item
.unit
@@ -220,6 +265,7 @@ export default function WarehouseRequest(props: Props) {
description={`Estado: ${labels[p.state] ?? p.state}`}
/>
+ {props.canViewReturns &&
Conferir devolução de excedentes}
Requisições ao armazém
@@ -237,6 +283,204 @@ export default function WarehouseRequest(props: Props) {
Local de utilização: {r.snapshot.destination}
Finalidade: {r.snapshot.reason}
+ {props.supplyTrace.ready &&
+ (props.supplyTrace.groups.length > 0 ||
+ props.health.needs_reservation) && (
+
+
+
+ A reposição serve o artigo neste armazém.
+ Após uma entrada, o autorizador confere o
+ stock utilizável e actualiza a reserva do
+ pedido; a saída acontece na entrega.
+
+ {props.canManageReservation &&
+ props.supplyTrace.groups.some(
+ (group) => group.unreserved_milli > 0,
+ ) && (
+ {
+ const command =
+ document.getElementById(
+ 'warehouse-command-reserve',
+ );
+ if (
+ command instanceof
+ HTMLDetailsElement
+ ) {
+ command.open = true;
+ command.scrollIntoView({
+ block: 'center',
+ behavior: 'smooth',
+ });
+ }
+ }}
+ >
+ Conferir e actualizar reserva
+
+ )}
+ {props.supplyTrace.groups.length === 0 && (
+
+ Ainda não existe ligação de reposição
+ registada para este pedido. A avaliação
+ depende da parametrização e das
+ necessidades detectadas.
+
+ )}
+ {props.supplyTrace.groups.map((group) => (
+
+
+ {group.item.code} ·{' '}
+ {group.item.name}
+
+
+ Saldo deste pedido sem reserva:{' '}
+ {group.unreserved_milli / 1000}{' '}
+ {group.item.unit}. Necessidade de
+ origem #{group.origin_need_id};
+ avaliação actual #
+ {group.current_need_id}.
+
+
+ {group.message}
+
+ {group.restricted && (
+
+ O detalhe de algumas compras
+ exige acesso ao respectivo
+ processo.
+
+ )}
+ {group.purchases.map((purchase) => (
+
+
+
+ Compra {purchase.number}
+ {' '}
+ ·{' '}
+ {(
+ {
+ draft: 'Rascunho a conferir e submeter',
+ pending:
+ 'Em aprovação',
+ approved:
+ 'Aprovada',
+ rejected:
+ 'Rejeitada',
+ } as Record<
+ string,
+ string
+ >
+ )[purchase.status] ??
+ purchase.status}
+
+ {purchase.next_task && (
+
+ Etapa pendente:{' '}
+ {purchase.next_task}
+
+ )}
+ {purchase.orders.length ===
+ 0 && (
+
+ Sem encomenda emitida
+ neste processo.
+
+ )}
+ {purchase.orders.map(
+ (order) => (
+
+
+ Encomenda{' '}
+ {order.href ? (
+
+ {
+ order.number
+ }
+
+ ) : (
+ order.number
+ )}{' '}
+ · Entradas
+ aceites
+ efectivas neste
+ armazém:{' '}
+ {order.effective_received_milli /
+ 1000}{' '}
+ {
+ group.item
+ .unit
+ }
+
+ {order.receipts
+ .length ===
+ 0 && (
+
+ Sem entrada
+ deste artigo
+ registada no
+ destino.
+
+ )}
+ {order.receipts.map(
+ (receipt) => (
+
+ Recebimento
+ #
+ {
+ receipt.id
+ }{' '}
+ ·{' '}
+ {
+ receipt.received_on
+ }{' '}
+ ·{' '}
+ {receipt.accepted_milli /
+ 1000}{' '}
+ {
+ group
+ .item
+ .unit
+ }
+ {receipt.reversed
+ ? ' · Estornado'
+ : ' · Aceite'}
+
+ ),
+ )}
+
+ ),
+ )}
+
+ ))}
+
+ ))}
+
+
+ )}
@@ -299,7 +543,60 @@ export default function WarehouseRequest(props: Props) {
? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.'
: 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
+ {props.health.lines.some((l) => l.attention) && (
+
+ {props.health.lines
+ .filter((l) => l.attention)
+ .map((l) => (
+
+ {r.snapshot.lines[l.index].item.code} ·{' '}
+ {l.attention}
+ {l.unreserved_milli > 0
+ ? ` Saldo sem reserva: ${l.unreserved_milli / 1000} ${r.snapshot.lines[l.index].item.unit}.`
+ : ''}
+
+ ))}
+
+ )}
+ {props.health.unconfirmed_deliveries > 0 && (
+
+ {props.health.unconfirmed_deliveries} entrega(s)
+ aguardam confirmação de recepção pelo requisitante.
+ Confirmar não gera nova saída.
+
+ )}
+
+
+
+ Conforme perfis, contexto, departamento e
+ independência actuais. Esta consulta não atribui
+ tarefas nem altera decisões anteriores.
+
+ {(
+ [
+ [
+ 'requester',
+ 'Requisição e confirmação de recepção',
+ ],
+ [
+ 'review',
+ 'Autorização e acompanhamento da reserva',
+ ],
+ ['delivery', 'Entrega pelo armazém'],
+ ] as const
+ ).map(([key, label]) => (
+
+ {label}: {' '}
+ {props.people[key].length
+ ? props.people[key]
+ .map((u) => u.name)
+ .join(', ')
+ : 'Sem pessoa elegível nas competências actualmente atribuídas; requer parametrização.'}
+
+ ))}
+
+
{!props.ready ? (
Autorização e entrega ainda não instaladas.
@@ -327,7 +624,7 @@ export default function WarehouseRequest(props: Props) {
/>
>
)}
- {props.canDeliver && (
+ {props.canDeliver && props.health.delivery_ready && (
;
+ checkedAt: string;
ready: boolean;
canPrepare: boolean;
items: Item[];
@@ -40,10 +54,13 @@ type Props = {
id: number;
status: string;
snapshot: Snapshot;
- progress?: { state: string };
+ progress?: { state: string; reserved: number[] };
+ health: WarehouseHealth;
+ actions: WarehouseActions;
}[];
prev_page_url: string | null;
next_page_url: string | null;
+ total: number;
};
};
const blank = (): Line => ({
@@ -53,6 +70,22 @@ const blank = (): Line => ({
equipment_id: '',
});
export default function Requests(props: Props) {
+ const filters = useForm({
+ search: props.filters.search,
+ state: props.filters.state,
+ warehouse_id: props.filters.warehouse_id?.toString() ?? '',
+ view: props.filters.view,
+ });
+ const viewUrl = (view: string) => {
+ const query = new URLSearchParams({
+ view,
+ state: 'all',
+ search: props.filters.search,
+ });
+ if (props.filters.warehouse_id)
+ query.set('warehouse_id', props.filters.warehouse_id.toString());
+ return '/stock/requisicoes?' + query.toString();
+ };
const form = useForm({
operation_key: crypto.randomUUID(),
warehouse_id: '',
@@ -76,17 +109,108 @@ export default function Requests(props: Props) {
Uso interno sem factura: a requisição autorizada acompanha a
- saída e as entregas. Reserva física exclusiva e geração
- automática de compra por falta ainda estão em implementação.
- A venda a terceiros pertence à composição SGF.
+ saída e as entregas. A autorização reserva o material
+ disponível. A compra automática por falta depende da
+ parametrização do artigo e armazém. Consulte a reposição
+ para acompanhar as necessidades e os rascunhos associados. A
+ venda a terceiros pertence à composição SGF.
Consultar disponibilidade (conforme o seu acesso)
+
+ {props.views.map((v) => (
+
+ {v.label} · {v.total}
+
+ ))}
+
+
+ {
+ e.preventDefault();
+ filters.get('/stock/requisicoes', {
+ preserveState: true,
+ preserveScroll: true,
+ });
+ }}
+ >
+
+ Referência, requisitante ou local
+
+ filters.setData('search', e.target.value)
+ }
+ placeholder="RA-123 ou local de utilização"
+ />
+
+
+ Armazém
+
+ filters.setData(
+ 'warehouse_id',
+ e.target.value,
+ )
+ }
+ >
+ Todos no meu âmbito
+ {props.warehouses.map((w) => (
+
+ {w.code} · {w.name}
+
+ ))}
+
+
+
+ Estado
+
+ filters.setData('state', e.target.value)
+ }
+ >
+ Todos
+ {Object.entries(warehouseStateLabels).map(
+ ([key, label]) => (
+
+ {label} · {props.states[key] ?? 0}
+
+ ),
+ )}
+
+
+
+ Aplicar filtros
+
+
+
+
+ {props.records?.total ?? 0} requisições nos filtros ·
+ Ordem de registo, mais antigas primeiro. Conferência:{' '}
+ {new Date(props.checkedAt).toLocaleString('pt-PT')}.
+
+
{!props.ready ? (
@@ -95,265 +219,292 @@ export default function Requests(props: Props) {
) : (
props.canPrepare && (
-
- {
- e.preventDefault();
- form.post('/stock/requisicoes', {
- preserveState: 'errors',
- });
- }}
- >
-
-
-
- Armazém
-
- form.setData(
- 'warehouse_id',
- e.target.value,
- )
- }
- >
- Seleccione
- {props.warehouses.map((w) => (
-
- {w.code} · {w.name}
-
- ))}
-
-
-
- Centro de custo
-
- form.setData(
- 'cost_center_id',
- e.target.value,
- )
- }
- >
-
- Seleccione do seu departamento
-
- {props.costCenters.map((c) => (
-
- {c.code} · {c.name}
-
- ))}
-
-
-
- Onde será utilizado
-
- form.setData(
- 'destination',
- e.target.value,
- )
- }
- />
-
-
- Prioridade
-
- form.setData(
- 'priority',
- e.target.value,
- )
- }
- >
-
- Normal
-
-
- Urgente
-
-
-
-
-
- Finalidade/justificação
-
- form.setData(
- 'reason',
- e.target.value,
- )
- }
- />
-
- {form.data.lines.map((row, index) => (
-
- Artigo {index + 1}
+ 0
+ ? true
+ : undefined
+ }
+ >
+
+ Preparar nova requisição
+
+
+ {
+ e.preventDefault();
+ form.post('/stock/requisicoes', {
+ preserveState: 'errors',
+ });
+ }}
+ >
+
+
- Artigo do cadastro
+ Armazém
- line(index, {
- stock_item_id:
- e.target.value,
- })
+ form.setData(
+ 'warehouse_id',
+ e.target.value,
+ )
}
>
Seleccione
- {props.items.map((item) => (
+ {props.warehouses.map((w) => (
- {item.code} ·{' '}
- {item.name} ({item.unit}
- )
+ {w.code} · {w.name}
))}
- Quantidade
+ Centro de custo
+
+ form.setData(
+ 'cost_center_id',
+ e.target.value,
+ )
+ }
+ >
+
+ Seleccione do seu
+ departamento
+
+ {props.costCenters.map((c) => (
+
+ {c.code} · {c.name}
+
+ ))}
+
+
+
+ Onde será utilizado
- line(index, {
- quantity:
- e.target.value,
- })
+ form.setData(
+ 'destination',
+ e.target.value,
+ )
}
/>
- Utilização
+ Prioridade
- line(index, {
- use: e.target.value,
- equipment_id: '',
- })
+ form.setData(
+ 'priority',
+ e.target.value,
+ )
}
>
-
- Uso interno no local
- indicado
+
+ Normal
-
- Em equipamento
+
+ Urgente
- {row.use === 'equipment' && (
+
+
+ Finalidade/justificação
+
+ form.setData(
+ 'reason',
+ e.target.value,
+ )
+ }
+ />
+
+ {form.data.lines.map((row, index) => (
+
+ Artigo {index + 1}
- Equipamento de destino
+ Artigo do cadastro
line(index, {
- equipment_id:
+ stock_item_id:
e.target.value,
})
}
>
- Seleccione do seu
- departamento
+ Seleccione
- {props.equipment.map(
- (eq) => (
-
- {eq.code} ·{' '}
- {eq.description}
-
- ),
- )}
+ {props.items.map((item) => (
+
+ {item.code} ·{' '}
+ {item.name} (
+ {item.unit})
+
+ ))}
- )}
- {form.data.lines.length > 1 && (
-
- form.setData(
- 'lines',
- form.data.lines.filter(
- (_, i) =>
- i !== index,
- ),
- )
- }
- >
- Retirar linha
-
- )}
-
- ))}
-
- = 50}
- onClick={() =>
- form.setData('lines', [
- ...form.data.lines,
- blank(),
- ])
- }
- >
- Acrescentar artigo
-
-
- Guardar rascunho
-
-
- {props.costCenters.length === 0 && (
-
- É necessário um centro de custo
- associado ao seu departamento.
-
- )}
-
-
+
+ Quantidade
+
+ line(index, {
+ quantity:
+ e.target.value,
+ })
+ }
+ />
+
+
+ Utilização
+
+ line(index, {
+ use: e.target.value,
+ equipment_id: '',
+ })
+ }
+ >
+
+ Uso interno no local
+ indicado
+
+
+ Em equipamento
+
+
+
+ {row.use === 'equipment' && (
+
+ Equipamento de destino
+
+ line(index, {
+ equipment_id:
+ e.target
+ .value,
+ })
+ }
+ >
+
+ Seleccione do seu
+ departamento
+
+ {props.equipment.map(
+ (eq) => (
+
+ {eq.code} ·{' '}
+ {
+ eq.description
+ }
+
+ ),
+ )}
+
+
+ )}
+ {form.data.lines.length > 1 && (
+
+ form.setData(
+ 'lines',
+ form.data.lines.filter(
+ (_, i) =>
+ i !== index,
+ ),
+ )
+ }
+ >
+ Retirar linha
+
+ )}
+
+ ))}
+
+ = 50
+ }
+ onClick={() =>
+ form.setData('lines', [
+ ...form.data.lines,
+ blank(),
+ ])
+ }
+ >
+ Acrescentar artigo
+
+
+ Guardar rascunho
+
+
+ {props.costCenters.length === 0 && (
+
+ É necessário um centro de custo
+ associado ao seu departamento.
+
+ )}
+
+
+
)
)}
@@ -368,18 +519,9 @@ export default function Requests(props: Props) {
RA-{record.id} ·{' '}
{
- (
- {
- draft: 'Rascunho',
- submitted: 'Submetida',
- authorized: 'Autorizada',
- partial:
- 'Parcialmente entregue',
- delivered: 'Entregue',
- closed: 'Encerrada',
- rejected: 'Rejeitada',
- } as Record
- )[record.progress?.state ?? 'draft']
+ warehouseStateLabels[
+ record.progress?.state ?? 'draft'
+ ]
}{' '}
· {record.snapshot.requester.name} ·{' '}
{record.snapshot.warehouse.name}
@@ -389,8 +531,8 @@ export default function Requests(props: Props) {
className="underline"
href={`/stock/requisicoes/${record.id}`}
>
- Abrir requisição, autorizar ou registar
- entrega
+ Consultar requisição e tarefas
+ disponíveis
Destino: {record.snapshot.destination}
@@ -401,6 +543,44 @@ export default function Requests(props: Props) {
{record.snapshot.cost_center.name}
Finalidade: {record.snapshot.reason}
+ {record.actions.canReview && (
+
+ Para si: autorizar ou rejeitar o
+ pedido.
+
+ )}
+ {record.actions.canManageReservation && (
+
+ Para si: acompanhar/actualizar a
+ reserva ou cancelar
+ fundamentadamente o saldo.
+
+ )}
+ {record.actions.canDeliver && (
+
+ {record.health.delivery_ready
+ ? 'Para si: há material reservado para registar a entrega efectiva.'
+ : 'Entrega pendente: conferir reserva, disponibilidade e destino antes de retirar material.'}
+
+ )}
+ {record.actions.canReceive &&
+ record.health.unconfirmed_deliveries >
+ 0 && (
+
+ Para si: confirmar{' '}
+ {
+ record.health
+ .unconfirmed_deliveries
+ }{' '}
+ entrega(s) recebida(s).
+
+ )}
+ {record.actions.canSubmit && (
+
+ Para si: conferir e submeter o
+ rascunho.
+
+ )}
{record.snapshot.lines.map(
(item, index) => (
@@ -411,6 +591,36 @@ export default function Requests(props: Props) {
{item.equipment
? `Equipamento ${item.equipment.code} · ${item.equipment.description}`
: item.destination}
+ {record.progress && (
+
+ {' '}
+ · Reservado{' '}
+ {record.progress
+ .reserved[index] /
+ 1000}
+
+ )}
+ {record.health.lines[index]
+ ?.unreserved_milli > 0 && (
+
+ {' '}
+ · Sem reserva{' '}
+ {record.health.lines[
+ index
+ ].unreserved_milli /
+ 1000}
+
+ )}
+ {record.health.lines[index]
+ ?.attention && (
+
+ {
+ record.health.lines[
+ index
+ ].attention
+ }
+
+ )}
),
)}
diff --git a/resources/js/pages/stock/returns.tsx b/resources/js/pages/stock/returns.tsx
new file mode 100644
index 0000000..75f9737
--- /dev/null
+++ b/resources/js/pages/stock/returns.tsx
@@ -0,0 +1,51 @@
+import { Head, Link } from '@inertiajs/react';
+import { useEffect, useRef, useState, type FormEvent } from 'react';
+import { PageTitle, Panel, controlClass } from '@/components/procurement';
+import { Button } from '@/components/ui/button';
+
+type Impact = { physical_before: string; physical_after: string; return_quantity_milli: string; not_returned_before: string; not_returned_after: string; original_movement_id: number; item: { code: string; name: string; unit: string }; warehouse: { code: string; name: string } };
+type Page = { page: number; last_page: number; total: number };
+type Row = { id: number; prepared_id: number; version: number; state: string; reason: string; evidence: string; preparer_id: number; author_id: number; created_at: string; impact: Impact };
+type Option = { delivery_event_id: number; line_index: number; delivered_milli: string; item: Impact['item'] };
+const milli = (value: string) => { const q = BigInt(value); return `${q / 1000n},${(q % 1000n).toString().padStart(3, '0')}`; };
+const states: Record = {prepared:'Por confirmar',confirmed:'Confirmada',withdrawn:'Retirada',rejected:'Recusada'};
+export function WarehouseReturnImpact({ impact: p }: { impact: Impact }) {
+ return Stock físico: {milli(p.physical_before)} → {milli(p.physical_after)}. Devolução: {milli(p.return_quantity_milli)} {p.item.unit}. Entrega ainda não devolvida: {milli(p.not_returned_before)} → {milli(p.not_returned_after)}. {p.item.code} · {p.warehouse.code} · Saída original #{p.original_movement_id}.
;
+}
+async function api(url: string, body?: object, signal?: AbortSignal): Promise {
+ const token = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.slice(11);
+ const response = await fetch(url, { method: body ? 'POST' : 'GET', credentials: 'same-origin', signal, headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...(token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
+ const result = await response.json(); if (!response.ok) throw new Error(result.errors ? Object.values(result.errors).flat().join(' ') : result.message || `Pedido recusado (${response.status})`); return result;
+}
+export default function WarehouseReturns({ requestId, actorId, canPrepare, canConfirm, canWithdraw=false }: { requestId: number | null; actorId: number; canPrepare: boolean; canConfirm: boolean; canWithdraw?:boolean }) {
+ const [history, setHistory] = useState<{ rows: Row[]; pagination: Page } | null>(null), [sources, setSources] = useState<{ options: Option[]; pagination: Page } | null>(null);
+ const [page, setPage] = useState(1), [sourcePage, setSourcePage] = useState(1), [state, setState] = useState('all'), [reload, setReload] = useState(0), [error, setError] = useState(''), [busy, setBusy] = useState(false), [preview, setPreview] = useState<{ id: number; impact: Impact; state?:string } | null>(null);
+ const generation = useRef(0), command = useRef<{ data: string; key: string } | null>(null);
+ useEffect(() => { const n = ++generation.current, abort = new AbortController(); setHistory(null); setSources(null); setError('');
+ Promise.all([api<{ rows: Row[]; pagination: Page }>(`/api/stock-returns?page=${page}&state=${state}`, undefined, abort.signal), requestId ? api<{ options: Option[]; pagination: Page }>(`/api/stock-returns/sources?request_id=${requestId}&source_page=${sourcePage}`, undefined, abort.signal) : Promise.resolve(null)])
+ .then(([h, s]) => { if (n === generation.current) { setHistory(h); setSources(s); } }).catch((e: Error) => { if (!abort.signal.aborted && n === generation.current) setError(e.message); });
+ return () => { generation.current++; abort.abort(); };
+ }, [requestId, actorId, page, sourcePage, state, reload]);
+ useEffect(() => { setPreview(null); command.current = null; setPage(1); setSourcePage(1); }, [requestId, actorId]);
+ async function prepare(e: FormEvent) { e.preventDefault(); const n = generation.current; setBusy(true); setError(''); const fields = Object.fromEntries(new FormData(e.currentTarget)), choice = sources?.options.find((o) => `${o.delivery_event_id}:${o.line_index}` === fields.source);
+ if (!choice) { setError('Seleccione uma entrega recebida.'); setBusy(false); return; }
+ const body = { delivery_event_id: choice.delivery_event_id, line_index: choice.line_index, quantity: fields.quantity, declared_unused: fields.declared_unused === 'on', reason: fields.reason, evidence: fields.evidence }, data = JSON.stringify(body);
+ if (command.current?.data !== data) command.current = { data, key: crypto.randomUUID() };
+ try { const p = await api<{ id: number; impact: Impact }>('/api/stock-returns/prepare', { ...body, operation_key: command.current.key }); if (n === generation.current) { setPreview(p); setReload((x) => x + 1); } } catch (err) { if (n === generation.current) setError((err as Error).message); } finally { setBusy(false); }
+ }
+ async function confirm(e: FormEvent, row: Row) { e.preventDefault(); const n = generation.current; setBusy(true); setError(''); const fields = Object.fromEntries(new FormData(e.currentTarget));
+ try { await api(`/api/stock-returns/${row.prepared_id}/confirm`, { version: 1, received_in_warehouse: fields.received_in_warehouse === 'on', reason: fields.reason }); if (n === generation.current) { setPreview(null); setReload((x) => x + 1); } } catch (err) { if (n === generation.current) setError((err as Error).message); } finally { setBusy(false); }
+ }
+ async function close(e:FormEvent,row:Row,action:'withdraw'|'reject'){e.preventDefault();const n=generation.current;setBusy(true);setError('');const fields=Object.fromEntries(new FormData(e.currentTarget));try{await api(`/api/stock-returns/${row.prepared_id}/${action}`,{version:1,reason:fields.reason});if(n===generation.current){setPreview(null);setReload(x=>x+1);}}catch(err){if(n===generation.current)setError((err as Error).message);}finally{setBusy(false);}}
+ return <>
+
{requestId ? 'Consultar a requisição de origem' : 'Voltar ao Painel Inicial'}
+ {error &&
{error}
}
setReload((x) => x + 1)}>Actualizar
+ {canPrepare && requestId && sources &&
Entrega e linha de origemSeleccione a origem {sources.options.map((o) => Entrega #{o.delivery_event_id} · {o.item.code} · {o.item.name} · entregue {milli(o.delivered_milli)} {o.item.unit} )}
+ Quantidade efectivamente devolvida Evidência de excedente e entrega física Fundamento Declaro que esta quantidade não foi consumidaConferir impacto
+ setSourcePage((x) => x - 1)}>Entregas anteriores = sources.pagination.last_page || busy} onClick={() => setSourcePage((x) => x + 1)}>Entregas seguintes }
+ {preview &&
{preview.state&&preview.state!=='prepared'?`Decisão já registada: ${states[preview.state]??'Arquivada'}. Sem novo efeito.`:'A preparação não altera stock. Aguarda outra identidade autorizada.'}
}
+
Estado { setState(e.target.value); setPage(1); }}>Todos Por confirmar Confirmados Retirados Recusados
+ {!history ? A consultar decisões…
: <>{history.rows.map((r) => Proposta #{r.prepared_id} · v{r.version} · {states[r.state]??'Estado arquivado'} {r.state==="confirmed"?"Entrada efectuada":r.state==="prepared"?"Impacto proposto":"Prévia arquivada, não executada"}
{r.reason} · Evidência: {r.evidence}
Preparação #{r.preparer_id} · Autor desta versão #{r.author_id} · {r.created_at}
+ {r.state === 'prepared' && canConfirm && r.preparer_id !== actorId && confirm(e, r)}>Fundamento da recepção Recebi fisicamente esta quantidade no armazém indicadoConfirmar entrada conferida }<>{r.state==="prepared"&&canWithdraw&&r.preparer_id===actorId&&Retirar proposta sem movimento close(e,r,"withdraw")}>Fundamento da retirada Retirar proposta }{r.state==="prepared"&&canConfirm&&r.preparer_id!==actorId&&Recusar recepção sem movimento close(e,r,"reject")}>Fundamento da recusa Recusar conferência }> )}{!history.rows.length && Sem decisões neste filtro.
} setPage((x) => x - 1)}>Anterior = history.pagination.last_page || busy} onClick={() => setPage((x) => x + 1)}>Seguinte >}
+ >;
+}
diff --git a/resources/js/pages/workflow/tasks.tsx b/resources/js/pages/workflow/tasks.tsx
new file mode 100644
index 0000000..b70aea3
--- /dev/null
+++ b/resources/js/pages/workflow/tasks.tsx
@@ -0,0 +1,188 @@
+import { Head, Link, router } from '@inertiajs/react';
+import { useState } from 'react';
+import { PageTitle, Panel, controlClass } from '@/components/procurement';
+import { Button } from '@/components/ui/button';
+
+type Props = {
+ area: 'procurement' | 'people';
+ modules: Record;
+ counts: Record;
+ checkedAt: string;
+ filters: { module: string; search: string };
+ tasks: {
+ total: number;
+ data: {
+ id: string;
+ module: string;
+ reference: string;
+ label: string;
+ blocked: boolean;
+ created_at: string | null;
+ due_on: string | null;
+ href: string;
+ }[];
+ prev_page_url: string | null;
+ next_page_url: string | null;
+ };
+};
+
+export default function Tasks({
+ area,
+ modules,
+ counts,
+ checkedAt,
+ filters,
+ tasks,
+}: Props) {
+ const [search, setSearch] = useState(filters.search);
+ const [module, setModule] = useState(filters.module);
+ const path =
+ area === 'people' ? '/pessoas/tarefas' : '/requisicoes/tarefas';
+ return (
+ <>
+
+
+
router.reload()}
+ >
+ Actualizar
+
+ }
+ />
+
+ {Object.entries(modules).map(([key, label]) => (
+
+ {label}: {counts[key] ?? 0}
+
+ ))}
+
+
+ {
+ event.preventDefault();
+ router.get(
+ path,
+ { module, search },
+ { preserveState: true },
+ );
+ }}
+ >
+
+ Módulo
+
+ setModule(event.target.value)
+ }
+ >
+ Todos nesta área
+ {Object.entries(modules).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ Referência ou tarefa
+
+ setSearch(event.target.value)
+ }
+ />
+
+
+ Filtrar
+
+
+ Limpar filtros
+
+
+
+ {tasks.total} tarefa(s) no filtro. Documentos mais
+ antigos aparecem primeiro; esta ordem não define
+ prioridade de atendimento. Consulta actualizada em{' '}
+ {new Date(checkedAt).toLocaleString('pt-PT')}.
+
+ {tasks.data.length === 0 ? (
+
+ Sem intervenções pendentes neste âmbito. A consulta
+ administrativa, por si só, não atribui tarefas
+ operacionais.
+
+ ) : (
+
+ {tasks.data.map((task) => (
+
+
+ {modules[task.module]} ·{' '}
+ {task.reference}
+
+
+ {task.label}
+
+ {task.blocked && (
+
+ A entrega aguarda conferência do
+ stock, qualidade ou reserva. Abra o
+ pedido para consultar o impedimento
+ e os responsáveis elegíveis.
+
+ )}
+ {task.due_on && (
+
+ Prazo registado no processo:{' '}
+ {task.due_on}
+
+ )}
+
+ ))}
+
+ )}
+
+ {tasks.prev_page_url && (
+ Anterior
+ )}
+ {tasks.next_page_url && (
+ Seguinte
+ )}
+
+
+
+ Abrir uma tarefa não decide nem conclui o processo. A lista
+ muda quando a intervenção é registada na origem ou quando
+ mudam as suas competências.
+
+
+ >
+ );
+}
diff --git a/resources/js/types/inventory.ts b/resources/js/types/inventory.ts
index d5c2077..bef1707 100644
--- a/resources/js/types/inventory.ts
+++ b/resources/js/types/inventory.ts
@@ -96,3 +96,33 @@ export const qualityStates: Record = {
reservations: 'Conforme com reservas',
nonconforming: 'Não conforme',
};
+export type WarehouseHealth = {
+ lines: {
+ index: number;
+ unreserved_milli: number;
+ attention: string | null;
+ source_changed: boolean;
+ ready: boolean;
+ }[];
+ needs_reservation: boolean;
+ needs_check: boolean;
+ unconfirmed_deliveries: number;
+ delivery_ready: boolean;
+};
+export type WarehouseActions = {
+ canSubmit: boolean;
+ canReview: boolean;
+ canDeliver: boolean;
+ canManageReservation: boolean;
+ canReceive: boolean;
+};
+export const warehouseStateLabels: Record = {
+ draft: 'Rascunho',
+ submitted: 'Submetida',
+ authorized: 'Autorizada',
+ partial: 'Parcialmente entregue',
+ delivered: 'Entregue',
+ closed: 'Encerrada',
+ rejected: 'Rejeitada',
+ cancelled: 'Saldo cancelado',
+};
diff --git a/routes/console.php b/routes/console.php
index 3c9adf1..1468266 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -2,6 +2,9 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Schedule;
+
+Schedule::command('sgc:replenishment-tick')->everyMinute()->withoutOverlapping(10);
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
diff --git a/routes/web.php b/routes/web.php
index c070de7..11c63c3 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -91,6 +91,14 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group(
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware([EnsureCompanyIsConfigured::class, EnsureWorkContext::class])->group(function () {
+ Route::get('api/stock-read', [\App\Http\Controllers\CommonStockReadController::class, 'index']);
+ Route::get('api/stock-returns', [\App\Http\Controllers\WarehouseReturnController::class, 'index']);
+ Route::get('api/stock-returns/sources', [\App\Http\Controllers\WarehouseReturnController::class, 'sources']);
+ Route::get('stock/devolucoes-internas', [\App\Http\Controllers\WarehouseReturnController::class, 'page']);
+ Route::post('api/stock-returns/prepare', [\App\Http\Controllers\WarehouseReturnController::class, 'prepare']);
+ Route::post('api/stock-returns/{id}/confirm', [\App\Http\Controllers\WarehouseReturnController::class, 'confirm'])->whereNumber('id');
+ Route::post('api/stock-returns/{id}/withdraw', [\App\Http\Controllers\WarehouseReturnController::class, 'withdraw'])->whereNumber('id');
+ Route::post('api/stock-returns/{id}/reject', [\App\Http\Controllers\WarehouseReturnController::class, 'reject'])->whereNumber('id');
Route::get('compras/fornecimento', [SupplyPlanningController::class, 'index'])->name('supply.index');
Route::get('stock/lotes', [InventoryLotController::class, 'index']);
Route::post('stock/lotes', [InventoryLotController::class, 'save']);
@@ -109,10 +117,16 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group(
Route::post('administracao/candidaturas/formularios', [SubmissionFormsController::class, 'save']);
Route::get('pessoas', [PeopleHiringController::class, 'index']);
Route::get('requisicoes/avisos', [WorkflowNoticeController::class, 'index']);
+ Route::get('requisicoes/tarefas', [\App\Http\Controllers\WorkflowTaskInboxController::class, 'procurement']);
+ Route::get('pessoas/tarefas', [\App\Http\Controllers\WorkflowTaskInboxController::class, 'people']);
Route::get('stock/disponibilidade', [WarehouseAvailabilityController::class, 'index']);
+ Route::get('stock/piloto',[\App\Http\Controllers\WarehousePilotController::class,'index']);
+ Route::get('stock/piloto.csv',[\App\Http\Controllers\WarehousePilotController::class,'export']);
Route::get('stock/reposicao', [\App\Http\Controllers\AutomaticPurchaseController::class, 'index']);
Route::post('stock/reposicao', [\App\Http\Controllers\AutomaticPurchaseController::class, 'save']);
+ Route::post('stock/reposicao/trabalhos/{id}/avaliar',[\App\Http\Controllers\AutomaticPurchaseController::class,'recover'])->whereNumber('id');
Route::post('stock/reposicao/{id}/avaliar', [\App\Http\Controllers\AutomaticPurchaseController::class, 'evaluate'])->whereNumber('id');
+ Route::get('stock/integracao/entregas', [\App\Http\Controllers\WarehouseExchangeController::class, 'index']);
Route::get('stock/requisicoes', [WarehouseRequestController::class, 'index']);
Route::post('stock/requisicoes', [WarehouseRequestController::class, 'store']);
Route::get('stock/requisicoes/{id}', [WarehouseRequestController::class, 'show'])->whereNumber('id');
diff --git a/scripts/apply-pilot-access-proposal.php b/scripts/apply-pilot-access-proposal.php
new file mode 100644
index 0000000..038c8b0
--- /dev/null
+++ b/scripts/apply-pilot-access-proposal.php
@@ -0,0 +1,200 @@
+make(Kernel::class)->bootstrap();
+$owner = DB::connection('pgsql')->getConfig();
+unset($owner['name']);
+if ($owner['host'] !== '127.0.0.1') {
+ throw new LogicException('Controlo de restauro exclusivamente local.');
+}
+$qa = [...$owner, 'url' => null, 'host' => $settings['DB_HOST'], 'port' => $settings['DB_PORT'] ?? '5432', 'database' => $settings['DB_DATABASE'], 'username' => $settings['DB_USERNAME'], 'password' => $settings['DB_PASSWORD']];
+config(['database.connections.pilot_apply' => $qa, 'database.connections.pilot_owner' => [...$owner, 'database' => 'postgres', 'url' => null], 'app.env' => 'payable-testing', 'work_context.composition' => 'sgc']);
+$app->detectEnvironment(fn () => 'payable-testing');
+DB::setDefaultConnection('pilot_apply');
+$db = DB::connection();
+$identity = $db->selectOne('select current_database() as db, current_user as role');
+if ($identity->db !== $settings['DB_DATABASE'] || $identity->role !== $settings['DB_USERNAME']) {
+ throw new LogicException('Identidade do destino inesperada.');
+}
+$request = Request::create($proposal['environment'].'/administracao/parametrizacao', 'GET', server: ['REMOTE_ADDR' => '127.0.0.1']);
+$app->instance('request', $request);
+$request->setUserResolver(fn ($guard = null) => auth()->guard($guard)->user());
+
+function pilotFingerprint($db, array $tables, bool $tenantProjection = false): array
+{
+ $hashes = [];
+ foreach ($tables as $table) {
+ $quoted = '"'.str_replace('"', '""', $table).'"';
+ $projection = $tenantProjection && $table === 'tenants' ? "(to_jsonb(t) - ARRAY['rules_provisional','work_context_policy','updated_at'])::text" : 'row_to_json(t)::text';
+ $rows = $db->select('select '.$projection.' as row from '.$quoted.' t order by '.$projection);
+ $hashes[$table] = hash('sha256', json_encode(array_column($rows, 'row'), JSON_THROW_ON_ERROR));
+ }
+
+ return $hashes;
+}
+
+function pilotPg(string $binary, array $arguments, array $connection): void
+{
+ $process = new Process([$binary, ...$arguments], null, ['PGHOST' => $connection['host'], 'PGPORT' => (string) $connection['port'], 'PGUSER' => $connection['username'], 'PGPASSWORD' => $connection['password']], timeout: 120);
+ $process->run();
+ if (! $process->isSuccessful()) {
+ throw new RuntimeException('Backup/restauro de controlo falhou. Nenhuma activação confirmada.');
+ }
+}
+
+$restore = null;
+$admin = DB::connection('pilot_owner');
+$failure = null;
+$result = null;
+try {
+ $result = $db->transaction(function () use ($db, $proposal, $proposalHash, $apply, $qa, $owner, $admin, &$restore) {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
+ $actor = User::query()->findOrFail(8);
+ auth()->guard('web')->setUser($actor);
+ app(ConflictGuard::class)->lockCompany($actor);
+ $configuration = app(OrganizationConfiguration::class);
+ $configuration->administrator($actor);
+ $access = app(OrganizationAccess::class);
+ $contexts = app(WorkContext::class);
+ $state = DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->lockForUpdate()->first();
+ if (($state->active ?? false) || (int) ($state->version ?? 1) !== $proposal['source_version']
+ || DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->exists()
+ || DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->exists()) {
+ throw new LogicException('A origem mudou; não substituir perfis ou atribuições existentes.');
+ }
+ $company = Tenant::query()->findOrFail($actor->tenant_id);
+ $expectedRules = OrganizationConfiguration::rules($proposal['current_rules']);
+ foreach (['first_limit_minor', 'second_limit_minor', 'approval_includes_tax'] as $field) {
+ if ($expectedRules[$field] !== $company->$field) {
+ throw new LogicException('As alçadas mudaram desde a proposta.');
+ }
+ }
+ if ($company->rules_provisional !== $proposal['current_rules']['provisional'] || (string) $company->default_tax_rate !== $proposal['current_rules']['default_tax_rate']
+ || $contexts->policy($actor)['enabled'] !== $proposal['enabled_contexts'] || User::query()->where('tenant_id', $actor->tenant_id)->count() !== count($proposal['users'])) {
+ throw new LogicException('A configuração ou a lista de avaliação mudou.');
+ }
+ $before = [];
+ foreach ($proposal['users'] as $person) {
+ $user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($person['id']);
+ if ($user->name !== $person['name'] || $user->department_id !== $person['department_id'] || $access->permissions($user) !== $person['current_permissions']
+ || $contexts->available($user) !== $person['current_contexts'] || $access->canRequest($user) !== $person['current_can_request']) {
+ throw new LogicException('Uma identidade ou competência mudou desde a conferência.');
+ }
+ $before[$user->id] = ['permissions' => $access->permissions($user), 'contexts' => $contexts->available($user), 'requester' => $access->canRequest($user), 'department_scoped' => $access->departmentScoped($user)];
+ }
+ if (! $apply) {
+ return ['result' => 'preflight_passed_no_changes', 'users' => count($before), 'source_version' => $proposal['source_version'], 'proposal_sha256' => $proposalHash];
+ }
+ $tables = array_map(fn ($row) => $row->tablename, $db->select("select tablename from pg_tables where schemaname='public' order by tablename"));
+ $baseline = pilotFingerprint($db, $tables);
+ $protected = array_values(array_diff($tables, ['organization_access', 'organization_profiles', 'organization_memberships', 'audit_logs', 'tenants']));
+ $tenantBaseline = pilotFingerprint($db, ['tenants'], true);
+ $directory = 'C:/laragon/data/sgf-tempo/backups/compras-pilot-access-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3));
+ if (! mkdir($directory, 0700, true)) {
+ throw new RuntimeException('Não foi possível guardar a cópia de recuperação.');
+ }
+ $dump = $directory.'/before.dump';
+ $snapshot = $db->selectOne('select pg_export_snapshot() as snapshot')->snapshot;
+ $bin = 'C:/Program Files/PostgreSQL/17/bin/';
+ pilotPg($bin.'pg_dump.exe', ['--format=custom', '--snapshot='.$snapshot, '--file='.$dump, '--dbname='.$qa['database']], $qa);
+ $restoreName = 'compras_pilot_restore_'.bin2hex(random_bytes(6));
+ $admin->statement('CREATE DATABASE "'.$restoreName.'"');
+ $restore = $restoreName;
+ pilotPg($bin.'pg_restore.exe', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], $owner);
+ config(['database.connections.pilot_restore' => [...$owner, 'database' => $restore, 'url' => null]]);
+ if (pilotFingerprint(DB::connection('pilot_restore'), $tables) !== $baseline) {
+ throw new RuntimeException('O restauro não coincide com a origem. Nenhuma atribuição aplicada.');
+ }
+ $reason = 'Piloto sintético SGC8005 autorizado pelo proprietário neste chat: resposta «autorizo» à proposta concreta de 02/11/2026.';
+ $version = $proposal['source_version'];
+ $profiles = [];
+ foreach ($proposal['profiles'] as $profile) {
+ $configuration->execute($actor, 'profile', ['version' => $version++, 'name' => $profile['name'], 'permissions' => $profile['permissions'], 'work_contexts' => $profile['work_contexts']]);
+ $profiles[$profile['key']] = DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->where('name', $profile['name'])->sole()->id;
+ }
+ foreach ($proposal['users'] as $person) {
+ $configuration->execute($actor, 'assign', ['version' => $version++, 'user_id' => $person['id'], 'profile_ids' => array_map(fn ($key) => $profiles[$key], $person['profile_keys']), 'maintenance' => false, 'expires_at' => null, 'reason' => $reason]);
+ }
+ $rules = $proposal['proposed_rules'];
+ $configuration->execute($actor, 'rules', ['version' => $version++, 'first_limit' => $rules['first_limit'], 'second_limit' => $rules['second_limit'], 'includes_tax' => $rules['includes_tax'], 'reason' => $reason]);
+ $configuration->execute($actor, 'work_contexts', ['version' => $version++, 'enabled' => $proposal['enabled_contexts'], 'reason' => $reason]);
+ $preview = app(OrganizationActivationPreview::class)->build($actor);
+ $administrators = $configuration->permanentAdministrators($actor->tenant_id);
+ sort($administrators);
+ if (! $preview['can_activate'] || $preview['losing_access'] !== 0 || $administrators !== $proposal['permanent_administrator_source_ids']) {
+ throw new RuntimeException('A conferência anterior à activação não coincide com a proposta.');
+ }
+ $configuration->execute($actor, 'activate', ['version' => $version, 'reason' => $reason]);
+ foreach ($proposal['users'] as $person) {
+ $user = User::query()->findOrFail($person['id']);
+ $permissions = $access->permissions($user);
+ $expected = $person['planned_permissions'];
+ sort($permissions);
+ sort($expected);
+ if ($permissions !== $expected || $contexts->available($user) !== $before[$user->id]['contexts']
+ || $access->canRequest($user) !== $before[$user->id]['requester'] || $access->departmentScoped($user) !== $before[$user->id]['department_scoped']) {
+ throw new RuntimeException('O acesso efectivo diverge da distribuição autorizada.');
+ }
+ }
+ if (pilotFingerprint($db, $protected) !== array_intersect_key($baseline, array_flip($protected)) || pilotFingerprint($db, ['tenants'], true) !== $tenantBaseline) {
+ throw new RuntimeException('Foi detectada alteração fora do âmbito autorizado; transacção cancelada.');
+ }
+
+ return ['result' => 'applied', 'checked_at' => gmdate('c'), 'environment' => $proposal['environment'], 'proposal_sha256' => $proposalHash,
+ 'source_version' => $proposal['source_version'], 'active_version' => $version + 1, 'users_preserved' => count($before), 'profiles_created' => count($profiles),
+ 'memberships_created' => DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->count(), 'administrators_preserved' => $proposal['permanent_administrator_source_ids'],
+ 'backup_file' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restore_verified_tables' => count($tables), 'protected_tables_preserved' => count($protected),
+ 'tenant_other_fields_preserved' => true, 'business_data_preserved' => true, 'payables_specific_grants_preserved' => true, 'new_accounts' => false,
+ 'new_stock_publications_or_policies' => false, 'business_decisions_executed' => false, 'sgf_installation_changed' => false, 'atomic_transaction' => true];
+ });
+} catch (Throwable $error) {
+ $failure = $error;
+ fwrite(STDERR, 'Operação não concluída ('.get_class($error).'). '.$error->getMessage().PHP_EOL);
+} finally {
+ if ($restore !== null && preg_match('/^compras_pilot_restore_[a-f0-9]{12}$/D', $restore) && $restore !== $qa['database']) {
+ DB::purge('pilot_restore');
+ $admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$restore]);
+ $admin->statement('DROP DATABASE "'.$restore.'"');
+ }
+}
+if ($failure !== null) {
+ exit(1);
+}
+if ($apply) {
+ file_put_contents($root.'/docs/proofs/pilot-access-activation.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+}
+echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
diff --git a/scripts/approval-routing-check.mjs b/scripts/approval-routing-check.mjs
index 3504209..ab05aec 100644
--- a/scripts/approval-routing-check.mjs
+++ b/scripts/approval-routing-check.mjs
@@ -17,6 +17,12 @@ await new Function('fs', 'assert', 'return (async()=>{' + common + `
assert.equal(warehouseDrafts.page.props.canPrepare,false);
const availability = await viewer.request('/stock/disponibilidade');
assert.equal(availability.status,200);
+ const replenishment = await viewer.request('/stock/reposicao');
+ assert.equal(replenishment.status,200);
+ assert.equal(replenishment.page.component,'stock/replenishment');
+ assert.equal(replenishment.page.props.ready,true);
+ assert.equal(replenishment.page.props.canPrepare,false);
+ assert.equal(replenishment.page.props.canReview,false);
const notices = await viewer.request('/requisicoes/avisos');
assert.equal(notices.status,200);
assert.equal(notices.page.component,'workflow/notices');
diff --git a/scripts/award-task-integration-http.mjs b/scripts/award-task-integration-http.mjs
new file mode 100644
index 0000000..beb6ac1
--- /dev/null
+++ b/scripts/award-task-integration-http.mjs
@@ -0,0 +1,36 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const routes = [];
+ for (const role of ['procurement', 'department_head', 'finance']) {
+ const viewer = client();
+ await viewer.enter(role);
+ const dashboard = await viewer.request('/dashboard');
+ const context = dashboard.page?.props.workContext ?? dashboard.page?.props.context;
+ if (context?.enforced) {
+ assert.ok(context.available?.includes('procurement.internal'));
+ assert.equal((await viewer.request('/contexto', 'POST', { area: 'procurement', option: 'procurement.internal' })).status, 302);
+ }
+ const response = await viewer.request('/requisicoes/tarefas?module=purchases');
+ assert.equal(response.status, 200);
+ assert.equal(response.page.props.filters.module, 'purchases');
+ const rows = response.page.props.tasks.data;
+ assert.ok(rows.every(row => row.module === 'purchases'));
+ const awards = rows.filter(row => ['award_approve', 'order_issue_review'].includes(row.task));
+ for (const task of awards) {
+ const detail = await viewer.request(task.href);
+ assert.equal(detail.status, 200);
+ assert.equal(detail.page.props[task.task === 'award_approve' ? 'canDecide' : 'canIssue'], true);
+ }
+ routes.push({ role, total_purchase_tasks: response.page.props.tasks.total, shown_award_tasks: awards.length, result: 'passed' });
+ }
+ await fs.writeFile('docs/proofs/award-task-integration-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, routes, existing_accounts_used: true,
+ business_commands: false, accounts_or_grants_changed: false, visual_review: false,
+ note: 'Only current first-page tasks are followed; disposable tests cover pending approval and issuance.'
+ }, null, 2));
+ console.log('Encaminhamento de adjudicacoes conferido com o ambito actual dos perfis existentes.');
+` + '})()')(fs, assert);
diff --git a/scripts/dev-evaluation.php b/scripts/dev-evaluation.php
index c57c1d4..8116fc6 100644
--- a/scripts/dev-evaluation.php
+++ b/scripts/dev-evaluation.php
@@ -62,6 +62,8 @@ try {
fwrite(STDOUT, "Compras — ambiente de desenvolvimento e avaliação\n$entry\nEscolha «Entrar como proprietário» ou um perfil de avaliação.\nMesma base persistente; sem migrações, seeds ou redefinição de contas.\n");
if (! in_array('--check', $argv, true)) {
+ $children['reposição'] = new Process([PHP_BINARY, 'scripts/replenishment-evaluation.php', '--daemon'], $root, timeout: null);
+ $children['reposição']->start();
$hot = is_file($root.'/public/hot') ? trim(file_get_contents($root.'/public/hot')) : '';
$host = parse_url($hot, PHP_URL_HOST);
$viteReady = in_array($host, ['127.0.0.1', 'localhost', '[::1]'], true)
diff --git a/scripts/finance-integration-http.mjs b/scripts/finance-integration-http.mjs
new file mode 100644
index 0000000..c6af105
--- /dev/null
+++ b/scripts/finance-integration-http.mjs
@@ -0,0 +1,39 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const routes = [];
+ for (const role of ['admin', 'finance']) {
+ const viewer = client();
+ await viewer.enter(role);
+ const dashboard = await viewer.request('/dashboard');
+ const context = dashboard.page?.props.workContext ?? dashboard.page?.props.context;
+ if (context?.enforced) {
+ assert.ok(context.available?.includes('procurement.internal'));
+ assert.equal((await viewer.request('/contexto', 'POST', { area: 'procurement', option: 'procurement.internal' })).status, 302);
+ }
+ for (const path of ['/facturas', '/pagamentos', '/requisicoes/tarefas?module=finance']) {
+ const response = await viewer.request(path);
+ assert.equal(response.status, 200, role + ' ' + path);
+ const props = response.page.props;
+ if (path.startsWith('/requisicoes')) {
+ assert.equal(props.modules.finance, 'Finanças');
+ assert.equal(props.filters.module, 'finance');
+ routes.push({ role, path, total: props.tasks.total, result: 'passed' });
+ } else {
+ const data = props.invoices?.data ?? props.payments?.data;
+ assert.ok(Array.isArray(data));
+ assert.equal(path === '/facturas' ? props.focusInvoiceId : props.focusPaymentId, null);
+ routes.push({ role, path, shown: data.length, result: 'passed' });
+ }
+ }
+ }
+ await fs.writeFile('docs/proofs/finance-integration-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, routes, existing_accounts_used: true,
+ business_commands: false, accounts_or_grants_changed: false, sgf_installation_changed: false, visual_review: false,
+ note: 'Payment consultation retains its existing access audit; no financial decision is executed.'
+ }, null, 2));
+ console.log('Facturas, pagamentos e tarefas financeiras conferidos com as contas existentes.');
+` + '})()')(fs, assert);
diff --git a/scripts/formal-task-integration-http.mjs b/scripts/formal-task-integration-http.mjs
new file mode 100644
index 0000000..939f29e
--- /dev/null
+++ b/scripts/formal-task-integration-http.mjs
@@ -0,0 +1,41 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const routes = [];
+ for (const role of ['procurement', 'finance', 'department_head']) {
+ const viewer = client();
+ await viewer.enter(role);
+ assert.equal((await viewer.request('/contexto', 'POST', { area: 'procurement', option: 'procurement.internal' })).status, 302);
+ const tasks = await viewer.request('/requisicoes/tarefas?module=purchases');
+ assert.equal(tasks.status, 200);
+ const formal = tasks.page.props.tasks.data.filter(task => ['qualification_complete', 'admission_review', 'recommendation_review'].includes(task.task));
+ for (const task of formal) {
+ const detail = await viewer.request(task.href);
+ assert.equal(detail.status, 200);
+ if (task.task === 'qualification_complete') assert.equal(detail.page.props.canAssess, true);
+ else assert.equal(detail.page.props.history.data[0]?.canReview, true);
+ }
+ const index = await viewer.request('/cotacoes');
+ assert.equal(index.status, 200);
+ const request = index.page.props.requisitions.data[0];
+ const components = [];
+ if (request) {
+ for (const [path, component] of [['qualificacao', 'purchases/qualification'], ['admissao', 'purchases/admission'], ['recomendacao', 'purchases/recommendation']]) {
+ const response = await viewer.request('/requisicoes/' + request.id + '/' + path);
+ assert.equal(response.status, 200);
+ assert.equal(response.page.component, component);
+ components.push({ component, result: 'passed' });
+ }
+ }
+ routes.push({ role, formal_tasks_shown: formal.length, task_links_followed: formal.length, components });
+ }
+ await fs.writeFile('docs/proofs/formal-task-integration-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, result: 'passed', routes,
+ business_commands: false, accounts_or_grants_changed: false, sgf_installation_changed: false,
+ visual_review: false, coverage: 'Existing current scope and first-page tasks only; pending decisions tested in disposable databases.'
+ }, null, 2));
+ console.log('Analise documental, admissao, recomendacao e tarefas conferidas nos ambitos existentes.');
+` + '})()')(fs, assert);
diff --git a/scripts/internal-supply-integration-http.mjs b/scripts/internal-supply-integration-http.mjs
new file mode 100644
index 0000000..c47e1c7
--- /dev/null
+++ b/scripts/internal-supply-integration-http.mjs
@@ -0,0 +1,30 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const viewer = client();
+ await viewer.enter('admin');
+ const dashboard = await viewer.request('/dashboard');
+ if (dashboard.page?.props.context?.enforced || dashboard.page?.props.workContext?.enforced) {
+ await viewer.request('/contexto', 'POST', { area: 'procurement', option: 'procurement.internal' });
+ }
+ const index = await viewer.request('/stock/requisicoes');
+ assert.equal(index.status, 200);
+ let checkedDetails = 0;
+ for (const record of (index.page?.props.records?.data ?? []).slice(0, 3)) {
+ const detail = await viewer.request('/stock/requisicoes/' + record.id);
+ assert.equal(detail.status, 200);
+ assert.equal(typeof detail.page.props.supplyTrace.ready, 'boolean');
+ assert.ok(Array.isArray(detail.page.props.supplyTrace.groups));
+ checkedDetails++;
+ }
+ await fs.writeFile('docs/proofs/internal-supply-integration-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, result: 'passed', internal_sgc_only: true,
+ warehouse_index: true, existing_request_details_checked: checkedDetails,
+ synthetic_journeys_in_disposable_tests: true, persistent_business_commands: false,
+ accounts_or_grants_changed: false, sgf_installation_changed: false, visual_review: false
+ }, null, 2));
+ console.log('Consulta da integração interna conferida; detalhes existentes verificados: ' + checkedDetails);
+` + '})()')(fs, assert);
diff --git a/scripts/organization-activation-preview-http.mjs b/scripts/organization-activation-preview-http.mjs
new file mode 100644
index 0000000..876995e
--- /dev/null
+++ b/scripts/organization-activation-preview-http.mjs
@@ -0,0 +1,34 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const viewer = client();
+ await viewer.enter('admin');
+ const response = await viewer.request('/administracao/parametrizacao');
+ assert.equal(response.status, 200);
+ const props = response.page.props;
+ const preview = props.activationPreview;
+ assert.equal(preview.ready, true);
+ assert.equal(preview.version, props.version);
+ assert.equal(preview.active, props.active);
+ assert.equal(preview.users.length, props.users.length);
+ const proof = {
+ checked_at: new Date().toISOString(), environment: base, result: 'passed', version: props.version,
+ active: props.active, users: preview.users.length, losing_access: preview.losing_access,
+ permanent_administrators: preview.permanent_administrators, can_activate: preview.can_activate,
+ provisional_rules: preview.provisional_rules, existing_account_used: true, business_commands: false,
+ accounts_or_grants_changed: false, sgf_installation_changed: false, visual_review: false
+ };
+ await fs.writeFile('docs/proofs/organization-activation-preview-http.json', JSON.stringify(proof, null, 2));
+ const snapshot = {
+ captured_at: proof.checked_at, environment: base, version: props.version, active: props.active,
+ company_rules: props.companyRules, enabled_contexts: props.enabledWorkContexts,
+ users: preview.users.map(person => ({ ...person, department_id: props.users.find(user => user.id === person.id)?.department_id ?? null })),
+ profiles: props.profiles, memberships: props.memberships, departments: props.departments, cost_centers: props.costCenters
+ };
+ await fs.writeFile('storage/app/pilot-access-source.json', JSON.stringify(snapshot, null, 2));
+ console.log(JSON.stringify({ ...proof, current_rules: props.companyRules,
+ registered: snapshot.users.map(person => ({ id: person.id, name: person.name, department_id: person.department_id, functions: person.current_permissions, contexts: person.current_contexts })) }, null, 2));
+` + '})()')(fs, assert);
diff --git a/scripts/pilot-access-activation-http.mjs b/scripts/pilot-access-activation-http.mjs
new file mode 100644
index 0000000..59429be
--- /dev/null
+++ b/scripts/pilot-access-activation-http.mjs
@@ -0,0 +1,86 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const proposal = JSON.parse(await fs.readFile('docs/proofs/pilot-access-proposal.json', 'utf8'));
+const applied = JSON.parse(await fs.readFile('docs/proofs/pilot-access-activation.json', 'utf8'));
+assert.equal(applied.result, 'applied');
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'proposal', 'applied', 'return (async()=>{' + common + `
+ const viewer = client();
+ await viewer.enter('admin');
+ const parameters = await viewer.request('/administracao/parametrizacao');
+ assert.equal(parameters.status, 200);
+ const p = parameters.page.props;
+ assert.equal(p.active, true);
+ assert.equal(p.version, applied.active_version);
+ assert.equal(p.profiles.length, 11);
+ assert.equal(p.memberships.length, 17);
+ assert.equal(p.users.length, 12);
+ assert.equal(p.activationPreview.permanent_administrators, 2);
+ assert.equal(p.activationPreview.losing_access, 0);
+ for (const person of proposal.users) {
+ const actual = p.activationPreview.users.find(user => user.id === person.id);
+ assert.equal(actual.name, person.name);
+ assert.deepEqual([...actual.current_permissions].sort(), [...person.planned_permissions].sort());
+ assert.deepEqual(actual.current_contexts, person.current_contexts);
+ assert.equal(actual.current_can_request, person.current_can_request);
+ assert.equal(p.users.find(user => user.id === person.id).department_id, person.department_id);
+ }
+ assert.equal(p.companyRules.first_limit, proposal.proposed_rules.first_limit);
+ assert.equal(p.companyRules.second_limit, proposal.proposed_rules.second_limit);
+ assert.equal(p.companyRules.includes_tax, proposal.proposed_rules.includes_tax);
+ assert.equal(p.companyRules.default_tax_rate, proposal.current_rules.default_tax_rate);
+ assert.equal(p.companyRules.provisional, false);
+ async function selectInternal(client, area = 'procurement') {
+ const response = await client.request('/contexto', 'POST', { area, option: area + '.internal' });
+ assert.equal(response.status, 302);
+ }
+ await selectInternal(viewer);
+ const readiness = await viewer.request('/stock/piloto');
+ assert.equal(readiness.status, 200);
+ const report = readiness.page.props.report;
+ for (const [group, ids] of [['prepare', [9]], ['review', [10]], ['deliver', [13]], ['supply_prepare', [11]], ['supply_review', [12]]]) {
+ assert.deepEqual(report.participants[group].map(user => user.id).sort((a, b) => a - b), ids);
+ }
+ assert.equal(report.checks.find(check => check.key === 'participants').ok, true);
+ assert.equal(report.participants.purchase_requester.length, 12);
+ const roles = [];
+ for (const [role, path, capability] of [
+ ['requester', '/stock/requisicoes', 'canPrepare'],
+ ['department_head', '/stock/requisicoes?view=review', null],
+ ['warehouse', '/stock/requisicoes?view=delivery', null],
+ ['procurement', '/stock/reposicao', 'canPrepare'],
+ ['finance', '/stock/reposicao', 'canReview']
+ ]) {
+ const person = client();
+ await person.enter(role);
+ await selectInternal(person);
+ const response = await person.request(path);
+ assert.equal(response.status, 200, role + ' ' + path);
+ if (capability) assert.equal(response.page.props[capability], true);
+ if (role === 'procurement') assert.equal(response.page.props.canReview, false);
+ if (role === 'finance') assert.equal(response.page.props.canPrepare, false);
+ if (['warehouse', 'department_head'].includes(role)) assert.equal(response.page.props.canPrepare, false);
+ assert.equal((await person.request('/administracao/parametrizacao')).status, 403);
+ const tasks = await person.request('/requisicoes/tarefas');
+ assert.equal(tasks.status, 200);
+ roles.push({ role, route: path, result: 'passed', tasks: tasks.page.props.tasks.total });
+ }
+ await selectInternal(viewer, 'people');
+ assert.equal((await viewer.request('/pessoas/tarefas')).status, 200);
+ const proof = {
+ checked_at: new Date().toISOString(), environment: base, result: 'passed', active: true,
+ version: p.version, users: 12, profiles: 11, memberships: 17, permanent_administrators: 2,
+ original_access_and_contexts_preserved: true, first_limit: p.companyRules.first_limit,
+ second_limit: p.companyRules.second_limit, includes_tax: p.companyRules.includes_tax, provisional_rules: false,
+ participants_separated: true, roles, readiness: {
+ warehouse_scenario_ready: report.warehouse_scenario_ready,
+ replenishment_scenario_ready: report.replenishment_scenario_ready,
+ client_configuration_complete: report.client_configuration_complete,
+ checks: report.checks.map(check => ({ key: check.key, ok: check.ok, detail: check.detail }))
+ }, business_commands_executed: false, new_accounts: false, sgf_changes: false, visual_review: false
+ };
+ await fs.writeFile('docs/proofs/pilot-access-activation-http.json', JSON.stringify(proof, null, 2));
+ console.log(JSON.stringify(proof, null, 2));
+` + '})()')(fs, assert, proposal, applied);
diff --git a/scripts/pilot-export-http.mjs b/scripts/pilot-export-http.mjs
new file mode 100644
index 0000000..52d4fb3
--- /dev/null
+++ b/scripts/pilot-export-http.mjs
@@ -0,0 +1,12 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+const source=await fs.readFile('scripts/supplier-evaluation-check.mjs','utf8');
+const common=source.slice(source.indexOf('const base ='),source.indexOf('const author ='));
+await new Function('fs','assert','return (async()=>{'+common+`
+ const viewer=client();await viewer.enter('admin');const dashboard=await viewer.request('/dashboard');
+ if(dashboard.page?.props.context?.enforced)await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'});
+ const response=await viewer.request('/stock/piloto.csv','GET',undefined,false);
+ assert.equal(response.status,200);assert.ok(response.text.includes('Conferido em'));assert.ok(response.text.includes('SGC autónomo'));assert.ok(response.text.includes('A preparar'));assert.ok(!response.text.includes('Solicitante — avaliação local'));
+ await fs.writeFile('docs/proofs/pilot-export-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,csv:'passed',dated_configuration_checks:true,account_register_exported:false,business_commands:false,accounts_or_grants_changed:false,visual_print_review:false},null,2));
+ console.log('Exportação administrativa do diagnóstico conferida em HTTP.');
+ `+'})()')(fs,assert);
diff --git a/scripts/prepare-pilot-access-proposal.php b/scripts/prepare-pilot-access-proposal.php
new file mode 100644
index 0000000..4cd01eb
--- /dev/null
+++ b/scripts/prepare-pilot-access-proposal.php
@@ -0,0 +1,66 @@
+ ['expected_role' => 'requester', 'name' => 'Requisitar ao armazém', 'permission' => 'warehouse_request_prepare'],
+ 10 => ['expected_role' => 'department_head', 'name' => 'Autorizar requisições internas', 'permission' => 'warehouse_request_review'],
+ 13 => ['expected_role' => 'warehouse', 'name' => 'Entregar requisições internas', 'permission' => 'warehouse_request_deliver'],
+ 11 => ['expected_role' => 'procurement', 'name' => 'Preparar reposição', 'permission' => 'supply_prepare'],
+ 12 => ['expected_role' => 'finance', 'name' => 'Conferir reposição', 'permission' => 'supply_review'],
+];
+$profiles = [];
+$people = [];
+$admins = [];
+foreach ($source['users'] as $person) {
+ if (count($person['current_permissions']) !== 1 || ! is_array($person['current_contexts']) || ! $person['current_can_request']) {
+ throw new LogicException('A origem deixou de corresponder às funções de desenvolvimento esperadas.');
+ }
+ $role = $person['current_permissions'][0];
+ $key = 'base.'.$role;
+ $base = ['key' => $key, 'name' => 'Piloto sintético — base '.$role,
+ 'permissions' => array_values(array_unique([$role, 'requester'])), 'work_contexts' => $person['current_contexts']];
+ if (isset($profiles[$key]) && $profiles[$key] !== $base) {
+ throw new LogicException('Contextos diferentes exigem proposta por pessoa, sem expansão implícita.');
+ }
+ $profiles[$key] = $base;
+ $keys = [$key];
+ $planned = $base['permissions'];
+ if ($role === 'admin') {
+ $admins[] = $person['id'];
+ }
+ if (isset($additions[$person['id']])) {
+ $extra = $additions[$person['id']];
+ if ($role !== $extra['expected_role'] || ! str_contains($person['name'], 'avaliação local')) {
+ throw new LogicException('Uma identidade proposta mudou. Confirmar os intervenientes antes de prosseguir.');
+ }
+ $extraKey = 'task.'.$person['id'];
+ $profiles[$extraKey] = ['key' => $extraKey, 'name' => 'Piloto sintético — '.$extra['name'], 'permissions' => [$extra['permission']], 'work_contexts' => ['procurement.internal']];
+ $keys[] = $extraKey;
+ $planned[] = $extra['permission'];
+ }
+ $people[] = ['id' => $person['id'], 'name' => $person['name'], 'department_id' => $person['department_id'],
+ 'current_permissions' => $person['current_permissions'], 'current_contexts' => $person['current_contexts'],
+ 'current_can_request' => $person['current_can_request'], 'profile_keys' => $keys,
+ 'planned_permissions' => $planned, 'added_task_permissions' => isset($additions[$person['id']]) ? [$additions[$person['id']]['permission']] : []];
+}
+sort($admins);
+if (count($people) !== 12 || $admins !== [6, 8] || count(array_intersect(array_column($people, 'id'), array_keys($additions))) !== 5) {
+ throw new LogicException('A lista de avaliação mudou. Produzir nova proposta revista.');
+}
+$proposal = ['status' => 'pending_owner_authorization', 'scope' => 'synthetic_local_pilot_only', 'environment' => $source['environment'],
+ 'prepared_at' => gmdate('c'), 'source_captured_at' => $source['captured_at'], 'source_sha256' => hash('sha256', $raw), 'source_version' => $source['version'],
+ 'current_rules' => $source['company_rules'], 'proposed_rules' => [...$source['company_rules'], 'provisional' => false],
+ 'enabled_contexts' => $source['enabled_contexts'], 'profiles' => array_values($profiles), 'users' => $people, 'permanent_administrator_source_ids' => $admins,
+ 'effects' => ['activate_saved_profiles' => true, 'preserve_existing_permissions_and_contexts' => true, 'preserve_legacy_request_preparation' => true,
+ 'make_existing_approval_limits_explicit_for_synthetic_pilot' => true, 'new_accounts' => false, 'change_existing_process_snapshots' => false,
+ 'change_payables_specific_grants' => false, 'publish_stock_or_set_minimum_quantities' => false, 'change_sgf' => false],
+ 'departments' => $source['departments'], 'cost_centers' => $source['cost_centers']];
+file_put_contents($root.'/docs/proofs/pilot-access-proposal.json', json_encode($proposal, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
+echo json_encode(['result' => 'proposal_prepared_not_applied', 'users' => count($people), 'profiles' => count($profiles), 'new_task_assignments' => count($additions), 'permanent_administrators' => $admins], JSON_THROW_ON_ERROR).PHP_EOL;
diff --git a/scripts/prepare-sgf-operational.php b/scripts/prepare-sgf-operational.php
index 2b3ef9e..0667e76 100644
--- a/scripts/prepare-sgf-operational.php
+++ b/scripts/prepare-sgf-operational.php
@@ -3,15 +3,19 @@
use Tempo\Purchasing\Delivery\AccessOverlay;
use Tempo\Purchasing\Delivery\BootOverlay;
use Tempo\Purchasing\Delivery\BudgetOverlay;
+use Tempo\Purchasing\Delivery\AdminUiOverlay;
require dirname(__DIR__).'/integrations/sgf/access/AccessOverlay.php';
require dirname(__DIR__).'/integrations/sgf/operational/BudgetOverlay.php';
require dirname(__DIR__).'/integrations/sgf/operational/BootOverlay.php';
+require dirname(__DIR__).'/integrations/sgf/operational/AdminUiOverlay.php';
$host = realpath($argv[1] ?? dirname(__DIR__).'/../sgf-tempo');
if (! $host) {
throw new RuntimeException('Host inexistente.');
}
$files = AccessOverlay::prepare($host) + BudgetOverlay::prepare($host) + BootOverlay::prepare($host);
+$frontend = AdminUiOverlay::prepare($host, $files);
+$files = $frontend['files'];
$root = dirname(__DIR__);
$support = [];
foreach (['integrations/sgf/operational', 'integrations/sgf/access', 'integrations/sgf/internal-requisitions', 'packages/purchasing/src'] as $directory) {
@@ -22,7 +26,9 @@ foreach (['integrations/sgf/operational', 'integrations/sgf/access', 'integratio
}
}
ksort($support);
+$support += $frontend['assets'];
$support['scripts/sgf-internal-install.php'] = file_get_contents($root.'/scripts/sgf-internal-install.php');
+$support['scripts/sgf-code-deploy.php'] = file_get_contents($root.'/scripts/sgf-code-deploy.php');
$support['bootstrap.php'] = file_get_contents($root.'/integrations/sgf/operational/entry.php');
$hash = hash('sha256', json_encode([$files, $support], JSON_THROW_ON_ERROR));
$directory = $root.'/storage/app/private/candidates/OPERATIONAL-CANDIDATE-'.substr($hash, 0, 16);
@@ -44,4 +50,41 @@ foreach ($support as $path => $contents) {
$manifest['support_files'][$path] = hash('sha256', $contents);
}
file_put_contents($directory.'/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
-echo json_encode(['directory' => $directory, 'installed' => false], JSON_THROW_ON_ERROR)."\n";
+$archives = $root.'/storage/app/private/candidate-archives';
+if (! is_dir($archives)) {
+ mkdir($archives, 0700, true);
+}
+$archive = $archives.'/'.basename($directory).'.zip';
+$creating = ! is_file($archive);
+$archivePath = $creating ? $archive.'.'.bin2hex(random_bytes(6)).'.tmp' : $archive;
+$zip = new ZipArchive;
+if ($zip->open($archivePath, $creating ? ZipArchive::CREATE | ZipArchive::EXCL : 0) !== true) {
+ throw new RuntimeException('Arquivo de candidata inválido.');
+}
+if ($creating) {
+ foreach ([...array_keys($manifest['support_files']), 'manifest.json'] as $file) {
+ $zip->addFile($directory.'/'.$file, $file);
+ }
+ if (! $zip->close()) {
+ throw new RuntimeException('Falha ao preparar arquivo.');
+ }
+ if ($zip->open($archivePath) !== true) {
+ throw new RuntimeException('Arquivo incompleto.');
+ }
+}
+if ($zip->numFiles !== count($manifest['support_files']) + 1) {
+ throw new RuntimeException('Conteúdo extra/ausente no arquivo.');
+}
+foreach ($manifest['support_files'] + ['manifest.json' => hash_file('sha256', $directory.'/manifest.json')] as $file => $expected) {
+ $size = filesize($directory.'/'.$file);
+ $entry = $zip->statName($file);
+ $body = $zip->getFromName($file, $size + 1);
+ if (! $entry || $entry['size'] !== $size || $body === false || ! hash_equals($expected, hash('sha256', $body))) {
+ throw new RuntimeException('Integridade do arquivo imutável divergente.');
+ }
+}
+$zip->close();
+if ($creating && ! rename($archivePath, $archive)) {
+ throw new RuntimeException('Falha ao concluir arquivo.');
+}
+echo json_encode(['directory' => $directory, 'archive' => ['file' => $archive, 'sha256' => hash_file('sha256', $archive), 'manifest_sha256' => hash_file('sha256', $directory.'/manifest.json')], 'installed' => false], JSON_THROW_ON_ERROR)."\n";
diff --git a/scripts/quotation-task-integration-http.mjs b/scripts/quotation-task-integration-http.mjs
new file mode 100644
index 0000000..c42cabb
--- /dev/null
+++ b/scripts/quotation-task-integration-http.mjs
@@ -0,0 +1,39 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const routes = [];
+ for (const role of ['procurement', 'finance', 'department_head']) {
+ const viewer = client();
+ await viewer.enter(role);
+ const dashboard = await viewer.request('/dashboard');
+ const context = dashboard.page?.props.workContext ?? dashboard.page?.props.context;
+ if (context?.enforced) {
+ assert.ok(context.available?.includes('procurement.internal'));
+ assert.equal((await viewer.request('/contexto', 'POST', { area: 'procurement', option: 'procurement.internal' })).status, 302);
+ }
+ const response = await viewer.request('/requisicoes/tarefas?module=purchases');
+ assert.equal(response.status, 200);
+ const tasks = response.page.props.tasks.data.filter(task => ['quotation_prepare', 'quotation_evaluate', 'quotation_check', 'comparison_review'].includes(task.task));
+ for (const task of tasks) {
+ const detail = await viewer.request(task.href);
+ assert.equal(detail.status, 200);
+ const props = detail.page.props;
+ if (task.task === 'quotation_prepare') {
+ assert.equal(props.canCreate, true);
+ assert.equal(props.prepare, true);
+ } else if (task.task === 'quotation_evaluate') {
+ assert.ok(props.quotations.find(quote => quote.id === props.focusQuotationId)?.canEvaluate);
+ }
+ }
+ routes.push({ role, purchase_tasks: response.page.props.tasks.total, shown_quotation_tasks: tasks.length, followed: tasks.length, result: 'passed' });
+ }
+ await fs.writeFile('docs/proofs/quotation-task-integration-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, routes, existing_accounts_used: true,
+ business_commands: false, accounts_or_grants_changed: false, sgf_installation_changed: false,
+ visual_review: false, coverage: 'Current first-page tasks only; complete pending journeys covered by disposable tests.'
+ }, null, 2));
+ console.log('Consulta ao mercado e ligacoes das tarefas conferidas com os perfis existentes.');
+` + '})()')(fs, assert);
diff --git a/scripts/replenishment-evaluation.php b/scripts/replenishment-evaluation.php
new file mode 100644
index 0000000..bfbe663
--- /dev/null
+++ b/scripts/replenishment-evaluation.php
@@ -0,0 +1,53 @@
+ $value) {
+ putenv($key.'='.$value);
+ $_ENV[$key] = $_SERVER[$key] = $value;
+}
+// This helper deliberately excludes the SGF composition and its production connection.
+putenv('SGC_PRODUCT_COMPOSITION=sgc');
+$_ENV['SGC_PRODUCT_COMPOSITION'] = $_SERVER['SGC_PRODUCT_COMPOSITION'] = 'sgc';
+$daemon = in_array('--daemon', $argv, true);
+$lock = fopen($root.'/storage/framework/replenishment-evaluation.lock', 'c');
+if ($lock === false) {
+ throw new RuntimeException('Não foi possível abrir o controlo da verificação periódica.');
+}
+if (! flock($lock, LOCK_EX | LOCK_NB)) {
+ fwrite(STDOUT, "Verificação periódica já em execução.\n");
+ if (! $daemon) {
+ exit(0);
+ }
+ // Standby until the running helper exits; never run two local schedulers together.
+ if (! flock($lock, LOCK_EX)) {
+ throw new RuntimeException('Não foi possível acompanhar o verificador existente.');
+ }
+}
+do {
+ $started = gmdate('c');
+ $process = new Process([PHP_BINARY, 'artisan', 'sgc:replenishment-tick'], $root, timeout: 55);
+ $process->run();
+ // Deliberately no raw exception/configuration content in the public status evidence.
+ $status = ['started_at' => $started, 'finished_at' => gmdate('c'), 'pid' => getmypid(), 'scope' => 'SGC evaluation 8005', 'result' => $process->isSuccessful() ? 'passed' : 'retry_pending', 'exit_code' => $process->getExitCode()];
+ $temporary = $root.'/storage/framework/replenishment-status.tmp';
+ file_put_contents($temporary, json_encode($status, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+ rename($temporary, $root.'/storage/framework/replenishment-status.json');
+ fwrite(STDOUT, json_encode($status, JSON_THROW_ON_ERROR)."\n");
+ if (! $daemon) {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ exit($process->isSuccessful() ? 0 : 1);
+ }
+ // Technical poll every minute; each work item has its own persisted next run time.
+ sleep(60);
+} while (true);
diff --git a/scripts/replenishment-pilot-http.mjs b/scripts/replenishment-pilot-http.mjs
new file mode 100644
index 0000000..f509c81
--- /dev/null
+++ b/scripts/replenishment-pilot-http.mjs
@@ -0,0 +1,18 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const viewer=client(); await viewer.enter('admin');
+ const dashboard=await viewer.request('/dashboard');
+ if(dashboard.page?.props.context?.enforced)await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'});
+ const page=await viewer.request('/stock/reposicao?need_state=pending');
+ assert.equal(page.status,200); assert.equal(page.page.component,'stock/replenishment');
+ const p=page.page.props; assert.equal(p.ready,true); assert.equal(p.recoveryReady,true);
+ assert.equal(p.canPrepare,false); assert.equal(p.canReview,false);
+ for(const key of ['rules','needs','history','workItems']){assert.ok(Array.isArray(p[key].data));assert.equal(typeof p[key].total,'number');}
+ assert.equal(p.filters.need_state,'pending'); assert.equal(p.schedulerStatus.result,'passed');
+ assert.ok(Date.now()-Date.parse(p.schedulerStatus.finished_at)<120000);
+ await fs.writeFile('docs/proofs/replenishment-pilot-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,component:'stock/replenishment',recovery_installed:true,pagination:['rules','needs','history','workItems'],filter:'pending',scheduler:p.schedulerStatus,administrator_read_only:true,business_commands:false,accounts_or_grants_changed:false,visual_browser_review:false},null,2));
+ console.log('Reposição: paginação, filtro, recuperação e passagem recente do serviço confirmados por HTTP.');
+ `+'})()')(fs,assert);
diff --git a/scripts/sgf-admin-ui-build.mjs b/scripts/sgf-admin-ui-build.mjs
new file mode 100644
index 0000000..5220f02
--- /dev/null
+++ b/scripts/sgf-admin-ui-build.mjs
@@ -0,0 +1,48 @@
+// Build in a private source copy; never writes the installed SGF or dependencies.
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {spawnSync} from 'node:child_process';
+
+const host=fs.realpathSync(process.argv[2]||'');
+if(fs.existsSync(path.join(host,'public/hot')))throw Error('Stop the source frontend watcher before freezing a candidate.');
+const work=path.join('C:/laragon/data/sgf-tempo/run','candidate-ui-'+crypto.randomBytes(6).toString('hex'));
+fs.mkdirSync(work,{recursive:true});
+const replace=(text,old,next)=>{if(text.split(old).length!==2)throw Error('Frontend baseline changed: '+old.slice(0,70));return text.replace(old,next);};
+fs.cpSync(path.join(host,'resources'),path.join(work,'resources'),{recursive:true});
+for(const file of ['package.json','vite.config.js'])fs.copyFileSync(path.join(host,file),path.join(work,file));
+fs.mkdirSync(path.join(work,'public/build'),{recursive:true});
+fs.symlinkSync(path.join(host,'node_modules'),path.join(work,'node_modules'),'junction');
+let app=fs.readFileSync(path.join(work,'resources/js/app.jsx'),'utf8');
+app=replace(app,"const options = (unit === null ? ['company.view', 'company.edit', 'unit.create', 'access.manage'] : ['unit.view', 'unit.edit']).filter(p => can(p, unit));", "const options = (list?.catalogue?.[unit === null ? 'company' : 'unit'] ?? []).filter(p => can(p, unit));");
+app=replace(app,'{labels[p]}','{list?.catalogue_labels?.[p] ?? labels[p] ?? p}');
+app=replace(app,'{labels[g.permission]}','{list?.catalogue_labels?.[g.permission] ?? labels[g.permission] ?? g.permission}');
+fs.writeFileSync(path.join(work,'resources/js/app.jsx'),app);
+let home=fs.readFileSync(path.join(work,'resources/js/PurchasingHome.jsx'),'utf8');
+home=replace(home,"const payables=unit!==null&&can('supplier_payables.view',unit);", "const payables=unit!==null&&can('supplier_payables.view',unit);\n const requisitions=unit!==null&&can('purchasing_requisitions.view',unit);");
+home=replace(home,'','
{requisitions&&
Requisições internas }{budget&&
Preparação de centros e dotações }');
+home=replace(home,'!budget&&!payables&&','!budget&&!payables&&!requisitions&&');
+fs.writeFileSync(path.join(work,'resources/js/PurchasingHome.jsx'),home);
+let config=fs.readFileSync(path.join(work,'vite.config.js'),'utf8');
+config=replace(config,'defineConfig({',`defineConfig({\n cacheDir: ${JSON.stringify(path.join(work,'vite-cache'))},`);
+fs.writeFileSync(path.join(work,'vite.config.js'),config);
+const result=spawnSync(process.execPath,[path.join(host,'node_modules/vite/bin/vite.js'),'build','--config',path.join(work,'vite.config.js'),'--configLoader','native'],{cwd:work,encoding:'utf8',windowsHide:true,maxBuffer:4*1024*1024,env:{...process.env,NODE_ENV:'production'}});
+fs.writeFileSync(path.join(work,'build.log'),(result.stdout||'')+(result.stderr||''));
+if(result.status!==0)throw Error('Candidate frontend build failed; private log '+path.join(work,'build.log'));
+const original=JSON.parse(fs.readFileSync(path.join(work,'public/build/manifest.json'),'utf8'));
+const assets={};
+for(const chunk of Object.values(original))for(const file of [chunk.file,...(chunk.css||[]),...(chunk.assets||[])]){
+ if(!/^assets\/[A-Za-z0-9_-]+\.(js|css)$/.test(file))throw Error('Unsupported frontend asset '+file);
+ assets['integrations/sgf/frontend/'+file]=path.join(work,'public/build',file);
+}
+const manifest=JSON.parse(JSON.stringify(original));
+for(const chunk of Object.values(manifest)){
+ chunk.file='modules/internal-requisitions/'+chunk.file;
+ if(chunk.css)chunk.css=chunk.css.map(p=>'modules/internal-requisitions/'+p);
+ if(chunk.assets)chunk.assets=chunk.assets.map(p=>'modules/internal-requisitions/'+p);
+}
+const manifestFile=path.join(work,'candidate-manifest.json');fs.writeFileSync(manifestFile,JSON.stringify(manifest,null,2)+'\n');
+const overlays={'resources/js/app.jsx':path.join(work,'resources/js/app.jsx'),'resources/js/PurchasingHome.jsx':path.join(work,'resources/js/PurchasingHome.jsx'),'public/build/manifest.json':manifestFile};
+const metadata={work,overlays,assets,dependency_install:false,source_host_modified:false};
+const metadataFile=path.join(work,'candidate-ui.json');fs.writeFileSync(metadataFile,JSON.stringify(metadata,null,2));
+console.log(JSON.stringify({metadata:metadataFile}));
diff --git a/scripts/sgf-code-deploy-proof.php b/scripts/sgf-code-deploy-proof.php
new file mode 100644
index 0000000..14a9ec1
--- /dev/null
+++ b/scripts/sgf-code-deploy-proof.php
@@ -0,0 +1,145 @@
+getMessage(), $text), $text);
+ }
+};
+$command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($root.'/scripts/prepare-sgf-operational.php').' '.escapeshellarg($host);
+exec($command, $output, $exit);
+if ($exit !== 0) {
+ throw new RuntimeException('Prepare failed');
+}
+$bundle = json_decode(implode("\n", $output), true, 16, JSON_THROW_ON_ERROR)['directory'];
+$manifest = json_decode(file_get_contents($bundle.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
+$hash = hash_file('sha256', $bundle.'/manifest.json');
+$make = function () use ($host, $manifest) {
+ $copy = sys_get_temp_dir().'/sgf-code-deploy-'.bin2hex(random_bytes(6));
+ mkdir($copy, 0700);
+ foreach ($manifest['host_overlays'] as $file => $values) {
+ if (! is_dir(dirname($copy.'/'.$file))) {
+ mkdir(dirname($copy.'/'.$file), 0700, true);
+ }copy($host.'/'.$file, $copy.'/'.$file);
+ }
+ mkdir($copy.'/packages/purchasing', 0700, true);
+ copy($host.'/packages/purchasing/current.json', $copy.'/packages/purchasing/current.json');
+ mkdir($copy.'/bootstrap/cache', 0700, true);
+ mkdir($copy.'/storage/framework', 0700, true);
+ file_put_contents($copy.'/bootstrap/cache/config.php', "true];");
+ file_put_contents($copy.'/bootstrap/cache/routes-v7.php', ' new CodeDeployment(''), 'Host explícito obrigatório');
+$deny(fn () => $deploy->plan('', $hash), 'Bundle explícito obrigatório');
+$plan = $deploy->plan($bundle, $hash);
+$check(! $plan['writes'] && ! is_dir($copy.'/storage/app'), 'plan is read-only');
+$deny(fn () => $deploy->plan($bundle, str_repeat('0', 64)), 'Manifesto divergente');
+file_put_contents($copy.'/.env', 'APP_CONFIG_CACHE=storage/framework/custom.php');
+$deny(fn () => $deploy->plan($bundle, $hash), 'Cache Laravel personalizado');
+unlink($copy.'/.env');
+$deny(fn () => $deploy->apply($bundle, $hash, $plan['target_sha256'], 'Synthetic operator', true), 'manutenção activa');
+file_put_contents($copy.'/storage/framework/down', '{"status":503}');
+$deny(fn () => $deploy->apply($bundle, $hash, $plan['target_sha256'], 'Synthetic operator', false), 'serviços de escrita parados');
+$deny(fn () => $deploy->apply($bundle, $hash, str_repeat('0', 64), 'Synthetic operator', true), 'Destino de código divergente');
+$beforeCache = file_get_contents($copy.'/bootstrap/cache/config.php');
+$result = $deploy->apply($bundle, $hash, $plan['target_sha256'], 'Synthetic operator', true);
+$id = $result['deployment_id'];
+$check($result['state'] === 'applied' && ! $result['activation_performed'] && is_file($copy.'/storage/framework/down'), 'applied offline without activation');
+$check(! is_file($copy.'/bootstrap/cache/config.php') && ! is_file($copy.'/bootstrap/cache/routes-v7.php') && is_file($copy.'/bootstrap/cache/unrelated.php'), 'invalidates known Laravel caches without touching unrelated files');
+foreach ($manifest['host_overlays'] as $file => $values) {
+ $check(hash_file('sha256', $copy.'/'.$file) === $values['after_sha256'], 'applied verified overlay '.$file);
+}
+$check($deploy->apply($bundle, $hash, $plan['target_sha256'], 'Synthetic operator', true)['replayed'], 'apply retry does not duplicate installation');
+$file = array_key_first($manifest['host_overlays']);
+$applied = file_get_contents($copy.'/'.$file);
+file_put_contents($copy.'/'.$file, $applied."\n// local change\n");
+$deny(fn () => $deploy->rollback($id, 'Synthetic operator', true), 'Código local alterado');
+$check(is_dir($copy.'/packages/internal-requisitions'), 'refused rollback preserves bundle and local code');
+file_put_contents($copy.'/'.$file, $applied);
+file_put_contents($copy.'/bootstrap/cache/config.php', 'true];');
+$deny(fn () => $deploy->rollback($id, 'Synthetic operator', true), 'Cache recriado');
+unlink($copy.'/bootstrap/cache/config.php');
+$rollback = $deploy->rollback($id, 'Synthetic operator', true);
+$check($rollback['state'] === 'rolled-back' && ! $rollback['data_schema_changed'] && is_file($copy.'/storage/framework/down'), 'rollback preserves maintenance and never touches data schema');
+$check(file_get_contents($copy.'/bootstrap/cache/config.php') === $beforeCache, 'rollback restores exact previous cache');
+foreach ($manifest['host_overlays'] as $file => $values) {
+ $check(hash_file('sha256', $copy.'/'.$file) === $values['before_sha256'], 'restored original overlay '.$file);
+}
+$check(! is_dir($copy.'/packages/internal-requisitions') && is_dir($copy.'/storage/app/private/module-deployments/'.$id.'/withdrawn'), 'withdrawn bundle preserved for diagnosis');
+$check(file_get_contents($copy.'/packages/purchasing/current.json') === $pointer, 'clone remains on original package pointer');
+$failed = $make();
+file_put_contents($failed.'/storage/framework/down', '{"status":503}');
+$recover = new CodeDeployment($failed);
+$plan2 = $recover->plan($bundle, $hash);
+$deny(fn () => $recover->apply($bundle, $hash, $plan2['target_sha256'], 'Synthetic failure', true, function ($step) {
+ if (str_starts_with($step, 'file:')) {
+ throw new RuntimeException('Injected deployment interruption');
+ }
+}), 'Injected deployment interruption');
+$check(is_file($failed.'/storage/framework/down'), 'interruption retains maintenance');
+$recover->rollback($id, 'Synthetic recovery', true);
+foreach ($manifest['host_overlays'] as $file => $values) {
+ $check(hash_file('sha256', $failed.'/'.$file) === $values['before_sha256'], 'interrupted deployment recovers '.$file);
+}
+$check(file_get_contents($host.'/packages/purchasing/current.json') === $pointer, 'destination pointer untouched');
+$switched = $make();
+file_put_contents($switched.'/storage/framework/down', '{"status":503}');
+$switchDeployment = new CodeDeployment($switched);
+$switchPlan = $switchDeployment->plan($bundle, $hash);
+$deny(fn () => $switchDeployment->apply($bundle, $hash, $switchPlan['target_sha256'], 'Synthetic commit failure', true, function ($step) {
+ if ($step === 'bundle-switched') {
+ throw new RuntimeException('Injected post-switch interruption');
+ }
+}), 'Injected post-switch interruption');
+$switchDeployment->rollback($id, 'Synthetic post-switch recovery', true);
+$check(! is_dir($switched.'/packages/internal-requisitions') && is_file($switched.'/storage/framework/down'), 'post-switch interruption recovers without exposing partial code');
+$lockPath = $switched.'/storage/app/private/module-deployments/deployment.lock';
+$lock = fopen($lockPath, 'c+');
+flock($lock, LOCK_EX);
+$deny(fn () => $switchDeployment->rollback($id, 'Synthetic concurrent recovery', true), 'Outra implantação em curso');
+flock($lock, LOCK_UN);
+fclose($lock);
+$cliCopy = $make();
+file_put_contents($cliCopy.'/storage/framework/down', '{"status":503}');
+$cli = function (array $args) use ($bundle) {
+ $command = implode(' ', array_map('escapeshellarg', [PHP_BINARY, $bundle.'/scripts/sgf-code-deploy.php', ...$args]));
+ $rows = [];
+ exec($command, $rows, $exit);
+ if ($exit !== 0) {
+ throw new RuntimeException('Code deployment CLI failed.');
+ }
+
+ return json_decode(implode("\n", $rows), true, 32, JSON_THROW_ON_ERROR);
+};
+$cliPlan = $cli(['plan', $cliCopy, $bundle, $hash]);
+$check(! $cliPlan['writes'], 'CLI plan verifies bundle without writes');
+$cliApplied = $cli(['apply', $cliCopy, $bundle, $hash, $cliPlan['target_sha256'], 'Synthetic CLI operator', '--offline']);
+$check($cliApplied['state'] === 'applied' && $cliApplied['maintenance_preserved'], 'CLI apply leaves services and maintenance under operator control');
+$cliRolled = $cli(['rollback', $cliCopy, $cliApplied['deployment_id'], 'Synthetic CLI recovery', '--offline']);
+$check($cliRolled['state'] === 'rolled-back' && file_get_contents($cliCopy.'/packages/purchasing/current.json') === $pointer, 'CLI rollback preserves 0.1.1 pointer');
+$proof = ['passed' => true, 'checks' => $checks, 'candidate' => $bundle, 'clones' => [$copy, $failed, $switched, $cliCopy], 'checked_at' => gmdate('c'), 'destination_version' => '0.1.1', 'destination_applied' => false, 'old_suites_repeated' => false, 'database_opened' => false, 'services_restarted' => false];
+file_put_contents($root.'/docs/proofs/sgf-code-deploy-delivery.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+echo json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
diff --git a/scripts/sgf-code-deploy.php b/scripts/sgf-code-deploy.php
new file mode 100644
index 0000000..9b34f64
--- /dev/null
+++ b/scripts/sgf-code-deploy.php
@@ -0,0 +1,20 @@
+ $deployment->plan($argv[3] ?? '', $argv[4] ?? ''),
+ 'apply' => $deployment->apply($argv[3] ?? '', $argv[4] ?? '', $argv[5] ?? '', $argv[6] ?? '', ($argv[7] ?? '') === '--offline'),
+ 'rollback' => $deployment->rollback($argv[3] ?? '', $argv[4] ?? '', ($argv[5] ?? '') === '--offline'),
+ 'cache' => $deployment->rebuildCaches($argv[3] ?? '', $argv[4] ?? '', ($argv[5] ?? '') === '--offline'),
+ default => throw new RuntimeException('Usage: plan
| apply --offline | rollback|cache --offline'),
+ };
+ echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
+} catch (Throwable $error) {
+ fwrite(STDERR, $error->getMessage()."\n");
+ exit(1);
+}
diff --git a/scripts/sgf-corrected-proof.php b/scripts/sgf-corrected-proof.php
new file mode 100644
index 0000000..7b68a46
--- /dev/null
+++ b/scripts/sgf-corrected-proof.php
@@ -0,0 +1,106 @@
+make(Kernel::class)->bootstrap();
+$root = dirname(__DIR__);
+$host = realpath($root.'/../sgf-tempo');
+$config = DB::connection('pgsql')->getConfig();
+if ($config['host'] !== '127.0.0.1') {
+ throw new RuntimeException('Local isolated cluster required.');
+}
+config(['database.connections.fixed_admin' => [...$config, 'url' => null, 'database' => 'postgres']]);
+$admin = DB::connection('fixed_admin');
+$suffix = bin2hex(random_bytes(6));
+$owner = 'fix_owner_'.$suffix;
+$runtime = 'fix_app_'.$suffix;
+$database = 'sgf_reproduction_'.$suffix;
+$ownerPassword = bin2hex(random_bytes(32));
+$runtimePassword = bin2hex(random_bytes(32));
+$roles = [];
+$created = false;
+$restoreCreated = false;
+$restoreDatabase = 'sgf_reproduction_restore_'.$suffix;
+$exit = 1;
+$work = sys_get_temp_dir().'/sgf-corrected-'.$suffix;
+mkdir($work, 0700);
+$pointer = file_get_contents($host.'/packages/purchasing/current.json');
+try {
+ $sourceSettings = Dotenv::parse(file_get_contents($host.'/.env'));
+ $sourceEnvironment = array_intersect_key($sourceSettings, array_flip(['DB_CONNECTION', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', 'APP_ENV']));
+ $sourceEnvironment += ['APP_BASE_PATH' => $host, 'DB_URL' => $sourceSettings['DB_URL'] ?? '', 'APP_CONFIG_CACHE' => $host.'/bootstrap/cache/config.php'];
+ $export = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-corrected-export.php'], $host, [...$sourceEnvironment, 'FIX_SOURCE_HOST' => $host, 'FIX_SCHEMA_FILE' => $work.'/source-schema.dump'], null, 90);
+ $export->mustRun();
+ $inventory = json_decode($export->getOutput(), true, 64, JSON_THROW_ON_ERROR);
+ if ($inventory['database'] !== $sourceSettings['DB_DATABASE']) {
+ throw new RuntimeException('Wrong reproduction source.');
+ }
+ file_put_contents($work.'/baseline.json', json_encode($inventory, JSON_THROW_ON_ERROR));
+ $prepare = new Process([PHP_BINARY, $root.'/scripts/prepare-sgf-operational.php', $host], $root);
+ $prepare->mustRun();
+ $artifact = json_decode($prepare->getOutput(), true, 16, JSON_THROW_ON_ERROR);
+ require $root.'/tests/Support/sgf-update-fixture.php';
+ $manifest = json_decode(file_get_contents($artifact['directory'].'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
+ $clone = updateFixture($host, $manifest, true);
+ foreach ([$owner => $ownerPassword, $runtime => $runtimePassword] as $role => $password) {
+ $admin->statement('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS PASSWORD '.$admin->getPdo()->quote($password));
+ $roles[] = $role;
+ }
+ $admin->statement('CREATE DATABASE "'.$database.'" OWNER "'.$owner.'"');
+ $created = true;
+ $env = ['APP_ENV' => 'production', 'APP_DEBUG' => 'false', 'APP_BASE_PATH' => $clone, 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)), 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => (string) $config['port'], 'DB_DATABASE' => $database, 'DB_USERNAME' => $owner, 'DB_PASSWORD' => $ownerPassword, 'SESSION_DRIVER' => 'file', 'SESSION_FILES' => $clone.'/storage/framework/sessions', 'SESSION_COOKIE' => 'corrected_'.$suffix, 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync',
+ 'FIX_SOURCE_HOST' => $host, 'FIX_CLONE' => $clone, 'FIX_BUNDLE' => $artifact['directory'], 'FIX_ROOT' => $root, 'FIX_WORK' => $work, 'FIX_RUNTIME' => $runtime, 'FIX_RUNTIME_PASSWORD' => $runtimePassword];
+ $restore = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_restore.exe', '--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$database, $work.'/source-schema.dump'], null, ['PGHOST' => '127.0.0.1', 'PGPORT' => (string) $config['port'], 'PGUSER' => $owner, 'PGPASSWORD' => $ownerPassword], null, 60);
+ $restore->mustRun();
+ $proofProcess = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-corrected-proof.php'], $host, $env, null, 180);
+ $proofProcess->mustRun();
+ try {
+ $proof = json_decode($proofProcess->getOutput(), true, 64, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ file_put_contents($work.'/failure.txt', $proofProcess->getOutput().$proofProcess->getErrorOutput());
+ throw new RuntimeException('Private diagnostic '.$work.'/failure.txt');
+ }
+ file_put_contents($work.'/expected.json', json_encode($proof['data_fingerprints'], JSON_THROW_ON_ERROR));
+ $admin->statement('CREATE DATABASE "'.$restoreDatabase.'" OWNER "'.$owner.'"');
+ $restoreCreated = true;
+ $restoreData = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_restore.exe', '--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restoreDatabase, $work.'/installed.dump'], null, ['PGHOST' => '127.0.0.1', 'PGPORT' => (string) $config['port'], 'PGUSER' => $owner, 'PGPASSWORD' => $ownerPassword], null, 90);
+ $restoreData->mustRun();
+ $readRestore = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-corrected-restored.php'], $host, [...$env, 'DB_DATABASE' => $restoreDatabase], null, 60);
+ $readRestore->mustRun();
+ $proof['data_recovery'] = json_decode($readRestore->getOutput(), true, 32, JSON_THROW_ON_ERROR);
+ $after = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/tests/Support/sgf-corrected-export.php'], $host, [...$sourceEnvironment, 'FIX_SOURCE_HOST' => $host, 'FIX_CHECK_ONLY' => '1'], null, 60);
+ $after->mustRun();
+ $afterState = json_decode($after->getOutput(), true, 64, JSON_THROW_ON_ERROR);
+ if ($afterState['source_security_fingerprints'] !== $inventory['source_security_fingerprints'] || $afterState['tables'] !== $inventory['tables']) {
+ throw new RuntimeException('Destination changed during isolated proof; reconcile.');
+ }
+ $proof['destination_security_and_schema_preserved'] = true;
+ if (! $proof['passed'] || file_get_contents($host.'/packages/purchasing/current.json') !== $pointer) {
+ throw new RuntimeException('Proof or destination preservation failed.');
+ }
+ $proof += ['artifact' => $artifact, 'source_schema_tables' => count($inventory['tables']), 'source_rows_exported' => 0, 'source_schema_sha256' => $inventory['schema_sha256'], 'runtime_and_migrator_separate' => true, 'destination_version' => '0.1.1', 'destination_changed' => false, 'prior_proofs_reused' => true, 'checked_at' => gmdate('c')];
+ $exit = 0;
+} catch (Throwable $error) {
+ fwrite(STDERR, $error->getMessage()."\n");
+} finally {
+ if ($restoreCreated) {
+ $admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$restoreDatabase]);
+ $admin->statement('DROP DATABASE "'.$restoreDatabase.'"');
+ }
+ if ($created) {
+ $admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()', [$database]);
+ $admin->statement('DROP DATABASE "'.$database.'"');
+ }foreach (array_reverse($roles) as $role) {
+ $admin->statement('DROP ROLE "'.$role.'"');
+ }
+}
+if ($exit === 0) {
+ $proof['test_database_and_roles_removed'] = true;
+ file_put_contents($root.'/docs/proofs/sgf-corrected-delivery.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+ echo json_encode($proof,JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
+}exit($exit);
diff --git a/scripts/sgf-demo-8039-http.mjs b/scripts/sgf-demo-8039-http.mjs
new file mode 100644
index 0000000..a2f0039
--- /dev/null
+++ b/scripts/sgf-demo-8039-http.mjs
@@ -0,0 +1,88 @@
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+const root='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939';
+const privateData=JSON.parse(fs.readFileSync(root+'/private/development.json','utf8'));
+assert.deepEqual([privateData.tenant_id,privateData.company_id,privateData.unit_id],[1,1,1]);
+const stateFile=root+'/private/requisitions-recovery/http-state.json';
+const state=fs.existsSync(stateFile)?JSON.parse(fs.readFileSync(stateFile,'utf8')):{};
+const persist=()=>fs.writeFileSync(stateFile,JSON.stringify(state,null,2));
+const key=name=>{state.keys??={};state.keys[name]??=crypto.randomUUID();persist();return state.keys[name];};
+const checks=[];
+const clients={};
+for(const p of privateData.profiles){
+ const jar=new Map();let csrf='',token='';
+ const api=async(path,method='GET',body,expected=200,commandKey)=>{
+ const r=await fetch('http://127.0.0.1:8039'+path,{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,'X-Purchasing-Context':'procurement.internal','Idempotency-Key':commandKey??crypto.randomUUID()},...(body===undefined?{}:{body:JSON.stringify(body)})});
+ for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}
+ const text=await r.text();let d;try{d=JSON.parse(text);}catch{d={message:text.slice(0,120)};}
+ assert((Array.isArray(expected)?expected:[expected]).includes(r.status),`${p.role} ${path}: expected ${expected}, got ${r.status}: ${d.message??text.slice(0,180)}`);
+ if(d.csrf)csrf=d.csrf;if(d.context?.token)token=d.context.token;
+ return d;
+ };
+ await api('/api/session');await api('/api/login','POST',{email:p.email,password:privateData.password});
+ await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1});clients[p.role]=api;
+}
+checks.push('Existing three synthetic identities authenticate without password changes');
+const prep=clients.preparador,review=clients.revisor,read=clients.consulta;
+const rights={1:['purchasing_requisitions.view','purchasing_requisitions.prepare','purchasing_requisitions.configure','purchasing_requisitions.approve.department_head','purchasing_budget.view','purchasing_budget.create','purchasing_budget.approve','purchasing_budget.record'],2:['purchasing_requisitions.view','purchasing_requisitions.approve.department_head','purchasing_budget.view','purchasing_budget.approve'],3:['purchasing_requisitions.view','purchasing_budget.view']};
+await prep('/api/internal-requisitions/access-initialisation','POST',{operation_key:key('initial'),reason:'Owner-confirmed fourteen rights in synthetic demonstration 8039 only.',administrators:[{user_id:1,rights:rights[1]}]});
+for(const [role,id] of [['revisor',2],['consulta',3]]){
+ const profile=privateData.profiles.find(p=>p.role===role);
+ for(const permission of rights[id])await prep('/api/accesses','POST',{email:profile.email,unit_id:1,permission,reason:'Owner-confirmed synthetic demonstration access 8039.'});
+}
+checks.push('Eight plus four plus two explicit authorised grants');
+for(const api of Object.values(clients))await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1,module_id:'purchasing'});
+const centre=await prep('/api/internal-requisitions/centres','POST',{operation_key:key('centre'),code:'SINTETICO-REPRO',reason:'Owner-authorised synthetic centre for demonstration only.'});
+state.centre_id=centre.centre_id;persist();
+await read('/api/internal-requisitions/centres','POST',{operation_key:key('read-centre'),code:'REFUSED',reason:'Synthetic read-only refusal verification.'},403);
+if(!state.budget_id){
+ await prep('/api/purchasing-budget/create','POST',{cost_center_id:state.centre_id,year:2026,kind:'operating',includes_tax:true,amount:'10000.00',reason:'Owner-authorised synthetic 10000 MZN allocation for 2026.'},200,key('budget'));
+ const list=await prep('/api/purchasing-budget');
+ fs.writeFileSync(root+'/private/requisitions-recovery/budget-response.json',JSON.stringify(list,null,2));
+ state.budget_pending=true;persist();
+}
+// Obtain exact budget identifier from the native response; no guessing IDs.
+const workspace=await prep('/api/purchasing-budget');
+const candidates=workspace.budgets??workspace.allocations??workspace.records;
+if(!state.budget_id){
+ assert(Array.isArray(candidates),'Inspect native budget response shape before proceeding');
+ const found=candidates.filter(b=>Number(b.cost_center_id??b.centre_id)===Number(state.centre_id)&&Number(b.year)===2026);
+ assert.equal(found.length,1,'Exactly one synthetic allocation');state.budget_id=found[0].id;persist();
+}
+if(!state.budget_approved){
+ await prep('/api/purchasing-budget/approve','POST',{budget_id:state.budget_id},403,key('self-budget'));
+ await read('/api/purchasing-budget/approve','POST',{budget_id:state.budget_id},403,key('read-budget'));
+ await review('/api/purchasing-budget/approve','POST',{budget_id:state.budget_id},200,key('approve-budget'));
+ state.budget_approved=true;persist();
+}
+checks.push('Synthetic allocation 10000 MZN approved by second identity; self and read-only approvals refused');
+const policy={version:0,reason:'Owner-confirmed synthetic OPER policy, demonstration 8039 only.',enabled:true,budget_required:true,first_limit_minor:1000000,second_limit_minor:2000000,approval_includes_tax:true,centres:[{id:state.centre_id,department_ref:'OPER'}],members:[{user_id:1,department_ref:'OPER'},{user_id:2,department_ref:'OPER'}]};
+await read('/api/internal-requisitions/policy','POST',policy,403);
+if(!state.policy_configured){await prep('/api/internal-requisitions/policy','POST',policy);state.policy_configured=true;persist();}
+const draft={operation_key:key('draft'),cost_center_id:state.centre_id,priority:'normal',needed_by:'2026-11-30',justification:'Synthetic demonstration requisition, no Tempo business operation.',items:[{description:'Synthetic demonstration material',category:'Consumiveis',purchase_type:'consumable',quantity:'1.000',unit:'UN',unit_price:'100.00',tax_rate:'0.00'}]};
+await read('/api/internal-requisitions/save','POST',{...draft,operation_key:key('read-save')},403);
+if(!state.request_id){const saved=await prep('/api/internal-requisitions/save','POST',draft);state.request_id=saved.id;persist();}
+const path='/api/internal-requisitions/'+state.request_id;
+if(!state.submitted){
+ const submitted=await prep(path+'/submit','POST',{operation_key:key('submit'),version:1});
+ state.submitted=submitted;persist();
+ const replay=await prep(path+'/submit','POST',{operation_key:key('submit'),version:1});
+ assert.equal(replay.id,state.request_id);checks.push('Submission retry reuses same requisition');
+}
+if(!state.approved){
+ const decision={operation_key:key('decision'),version:state.submitted.version,decision:'approved'};
+ await prep(path+'/decide','POST',decision,403);
+ await read(path+'/decide','POST',decision,[403,404]);
+ await read(path+'/submit','POST',{operation_key:key('read-submit'),version:state.submitted.version},[403,404]);
+ const approved=await review(path+'/decide','POST',decision);assert.equal(approved.status,'approved');state.approved=true;persist();
+}
+for(const api of [prep,review]){const d=await api(path);assert.equal(d.record.status,'approved');assert.equal(d.history.length,3);}
+await read(path,'GET',undefined,404);
+await read('/api/internal-requisitions');
+await read('/api/accesses','GET',undefined,403);
+checks.push('Full request lifecycle, independent approval and three-event history consulted by preparer and reviewer');
+checks.push('Existing record visibility rule returns 404 to read-only third identity; listing remains accessible; no extra rights granted');
+checks.push('Read-only profile refused centre, budget approval, policy, preparation, submission, decision and access administration');
+fs.writeFileSync('C:/laragon/www/compras/docs/proofs/sgf-demo-8039-http.json',JSON.stringify({passed:true,url:'http://127.0.0.1:8039',scope:'1:1:1',rights,centre_id:state.centre_id,budget_id:state.budget_id,request_id:state.request_id,checks,checked_at:new Date().toISOString(),visual_review:false,real_installation_changed:false},null,2));
+console.log(JSON.stringify({passed:true,checks,request_id:state.request_id}));
diff --git a/scripts/sgf-demo-8039.php b/scripts/sgf-demo-8039.php
new file mode 100644
index 0000000..8531a75
--- /dev/null
+++ b/scripts/sgf-demo-8039.php
@@ -0,0 +1,84 @@
+make(Kernel::class)->bootstrap();
+$db=DB::connection();
+$identity=$db->selectOne("select current_database() as db,current_user as role,current_setting('cluster_name') as cluster,inet_server_port() as port");
+if ($app->basePath()!==str_replace('/',DIRECTORY_SEPARATOR,$host) && str_replace('\\','/',$app->basePath())!==$host) throw new RuntimeException('Wrong application folder');
+if ($identity->db!=='sgf_modules_dev_2576427d1939'||$identity->role!=='sgf_modules_qa_2576427d1939'||$identity->cluster!=='sgf_modular_regression_2576427d1939'||(int)$identity->port!==55439||config('app.url')!=='http://127.0.0.1:8039') throw new RuntimeException('Wrong isolated identity');
+if (!$db->table('units')->where(['id'=>1,'tenant_id'=>1,'company_id'=>1])->exists()) throw new RuntimeException('Wrong scope');
+$private=json_decode(file_get_contents(dirname($host).'/private/development.json'),true,32,JSON_THROW_ON_ERROR);
+if ([$private['tenant_id'],$private['company_id'],$private['unit_id']]!==[1,1,1]) throw new RuntimeException('Wrong synthetic provenance');
+foreach([1=>'Preparador',2=>'Revisor',3=>'Consulta'] as $id=>$suffix) if(!str_ends_with($db->table('users')->where('id',$id)->value('name'),$suffix)) throw new RuntimeException('Wrong synthetic user');
+if(!class_exists(Installation::class))require_once $bundle.'/integrations/sgf/operational/bootstrap.php';
+require_once $bundle.'/integrations/sgf/operational/CodeDeployment.php';
+$deploy=new CodeDeployment($host);
+$mh=$proof['artifact']['archive']['manifest_sha256'];
+if ($action==='prepare') {
+ if(is_dir($recovery)) throw new RuntimeException('Recovery already exists; inspect before retry');
+ $plan=$deploy->plan($bundle,$mh);
+ mkdir($recovery,0700,true);
+ $manifest=json_decode(file_get_contents($bundle.'/manifest.json'),true);
+ foreach([...array_keys($manifest['host_overlays']),'.env','config/modules.php','packages/purchasing/current.json'] as $file){
+ if(!is_dir(dirname($recovery.'/files/'.$file)))mkdir(dirname($recovery.'/files/'.$file),0700,true);
+ copy($host.'/'.$file,$recovery.'/files/'.$file);
+ }
+ $cfg=$db->getConfig();
+ $dump=new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe','--format=custom','--file='.$recovery.'/before.dump','--dbname='.$identity->db],null,['PGHOST'=>'127.0.0.1','PGPORT'=>'55439','PGUSER'=>$identity->role,'PGPASSWORD'=>$cfg['password']],null,90);
+ $dump->mustRun();
+ (new Process(['C:/Program Files/PostgreSQL/17/bin/pg_restore.exe','--list',$recovery.'/before.dump']))->mustRun();
+ $baseline=['identity'=>$identity,'host'=>$host,'code_plan'=>$plan,'backup_sha256'=>hash_file('sha256',$recovery.'/before.dump'),'grants'=>$db->table('permission_grants')->orderBy('id')->get()->all(),'users_sha256'=>hash('sha256',json_encode($db->table('users')->orderBy('id')->get())),'pointer_sha256'=>hash_file('sha256',$host.'/packages/purchasing/current.json')];
+ file_put_contents($recovery.'/baseline.json',json_encode($baseline,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ echo json_encode(['prepared'=>true,'identity'=>$identity,'host'=>$host,'recovery'=>$recovery,'backup_sha256'=>$baseline['backup_sha256'],'code_plan'=>$plan],JSON_PRETTY_PRINT);
+} elseif($action==='install') {
+ $baseline=json_decode(file_get_contents($recovery.'/baseline.json'),true,64,JSON_THROW_ON_ERROR);
+ if(!hash_equals($baseline['backup_sha256'],hash_file('sha256',$recovery.'/before.dump')))throw new RuntimeException('Backup changed');
+ if(json_decode(json_encode($db->table('permission_grants')->orderBy('id')->get()),true)!==$baseline['grants'])throw new RuntimeException('Grants changed since backup');
+ if(is_file($host.'/storage/framework/down'))throw new RuntimeException('Unexpected prior maintenance');
+ file_put_contents($host.'/storage/framework/down',json_encode(['time'=>time(),'retry'=>60]));
+ $migration=Installation::migrate($db,Installation::target($db),$baseline['backup_sha256'],'Owner-authorised synthetic 8039 setup');
+ $applied=$deploy->apply($bundle,$mh,$baseline['code_plan']['target_sha256'],'Owner-authorised synthetic 8039 setup',true);
+ file_put_contents($recovery.'/applied.json',json_encode($applied,JSON_PRETTY_PRINT));
+ $config=['enabled'=>true,'target'=>Installation::target($db),'scopes'=>['1:1:1']];
+ file_put_contents($host.'/config/purchasing-operational.php','true];');
+ file_put_contents($host.'/config/purchasing-access.php','["1:1:1"]];');
+ // The development server reloads PHP for every request; keep normal uncached config.
+ foreach(['config.php','routes-v7.php','events.php'] as $file)if(is_file($host.'/bootstrap/cache/'.$file))throw new RuntimeException('Unexpected native cache; retain maintenance and reconcile');
+ unlink($host.'/storage/framework/down');
+ echo json_encode(['installed_synthetic_only'=>true,'migration'=>$migration,'deployment'=>$applied,'recovery'=>$recovery],JSON_PRETTY_PRINT);
+} elseif($action==='verify') {
+ $baseline=json_decode(file_get_contents($recovery.'/baseline.json'),true,64,JSON_THROW_ON_ERROR);
+ $http=json_decode(file_get_contents($root.'/docs/proofs/sgf-demo-8039-http.json'),true,64,JSON_THROW_ON_ERROR);
+ $current=$db->table('permission_grants')->orderBy('id')->get()->all();
+ $oldById=[];foreach($current as $grant)$oldById[$grant->id]=json_decode(json_encode($grant),true);
+ foreach($baseline['grants'] as $grant)if(($oldById[$grant['id']]??null)!==$grant)throw new RuntimeException('Existing grant changed');
+ $oldIds=array_column($baseline['grants'],'id');
+ $added=array_values(array_filter($current,fn($g)=>!in_array($g->id,$oldIds,true)));
+ if(count($added)!==14)throw new RuntimeException('Expected exactly fourteen new grants');
+ foreach($added as $grant)if([$grant->tenant_id,$grant->company_id,$grant->unit_id]!==[1,1,1]||!in_array($grant->permission,$http['rights'][$grant->user_id]??[],true))throw new RuntimeException('Unapproved new grant');
+ if(hash('sha256',json_encode($db->table('users')->orderBy('id')->get()))!==$baseline['users_sha256'])throw new RuntimeException('Existing accounts changed');
+ if(hash_file('sha256',$host.'/packages/purchasing/current.json')!==$baseline['pointer_sha256'])throw new RuntimeException('Pointer changed');
+ $requests=$db->table('ir_requests')->where(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1])->get();
+ if(count($requests)!==1||$requests[0]->status!=='approved'||$requests[0]->requester_id!==1)throw new RuntimeException('Unexpected request state');
+ if($db->table('cb_budget_entries')->where('kind','reserve')->count()!==1)throw new RuntimeException('Expected exactly one reservation');
+ $budget=$db->table('cb_budgets')->first();
+ $decision=$db->table('ir_steps')->first();
+ $report=['passed'=>true,'identity'=>$identity,'scope'=>'1:1:1','added_grants'=>$added,'existing_grants_preserved'=>true,'existing_accounts_preserved'=>true,'single_approved_request'=>true,'single_reservation'=>true,'reservation'=>$db->table('cb_budget_entries')->where('kind','reserve')->first(),'budget'=>$budget,'decision'=>$decision,'pointer_preserved'=>true,'backup_sha256'=>$baseline['backup_sha256'],'recovery_directory'=>$recovery,'checked_at'=>gmdate('c'),'owner_runtime_limitation'=>true,'visual_review'=>false,'read_only_record_visibility_limitation'=>true];
+ file_put_contents($root.'/docs/proofs/sgf-demo-8039-delivery.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ file_put_contents($host.'/config/purchasing-access.php','[]];');
+ echo json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
+} else throw new RuntimeException('Use prepare, install or verify');
diff --git a/scripts/sgf-installation-inspect.php b/scripts/sgf-installation-inspect.php
new file mode 100644
index 0000000..a7eaf35
--- /dev/null
+++ b/scripts/sgf-installation-inspect.php
@@ -0,0 +1,53 @@
+make(Kernel::class)->bootstrap();
+$db = null;
+try {
+ $db = DB::connection();
+ if ($db->getDriverName() !== 'pgsql') {
+ throw new RuntimeException('PostgreSQL expected');
+ }
+ $db->beginTransaction();
+ $db->statement('SET TRANSACTION READ ONLY');
+ $identity = $db->selectOne('select current_database() as database, inet_server_addr()::text as address, inet_server_port() as port');
+ $tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
+ $required = ['users', 'tenants', 'companies', 'units', 'company_memberships', 'permission_grants', 'identity_audit'];
+ $budget = ['cb_centres', 'cb_origins', 'cb_budgets', 'cb_budget_entries', 'cb_budget_consumptions', 'cb_budget_reinforcements', 'cb_audit'];
+ $internal = ['ir_requests', 'ir_steps', 'ir_events', 'ir_commands', 'ir_policies', 'ir_policy_versions', 'sgc_installations'];
+ $column = $db->selectOne("select character_maximum_length from information_schema.columns where table_schema='public' and table_name='permission_grants' and column_name='permission'");
+ $constraint = $db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
+ $privileges = $db->selectOne("select has_schema_privilege(current_user,'public','CREATE') as can_create,pg_has_role(current_user,(select relowner from pg_class where oid='permission_grants'::regclass),'USAGE') as can_alter_permissions");
+ $preserve = [];
+ foreach (['permission_grants', 'company_memberships', 'identity_audit'] as $table) {
+ $rows = $db->select('select to_jsonb(t)::text as value from "'.$table.'" t order by to_jsonb(t)::text');
+ $preserve[$table] = ['rows' => count($rows), 'sha256' => hash('sha256', json_encode(array_column($rows, 'value'), JSON_THROW_ON_ERROR))];
+ }
+ $counts = ['companies' => $db->table('companies')->count(), 'units' => $db->table('units')->count(), 'company_admin_grants' => $db->table('permission_grants')->where('permission', 'access.manage')->whereNull('unit_id')->count()];
+ $roles = $db->selectOne("select current_user as runtime_role,(select pg_get_userbyid(relowner) from pg_class where oid='permission_grants'::regclass) as permissions_owner");
+ $ownerCapabilities = $db->selectOne("select rolcanlogin as can_login,has_schema_privilege(rolname,'public','CREATE') as can_create_public from pg_roles where rolname=?", [$roles->permissions_owner]);
+ $state = ['checked_at' => gmdate('c'), 'host' => $host, 'app_url' => config('app.url'), 'environment' => $app->environment(), 'php' => PHP_VERSION, 'database' => ['driver' => $db->getDriverName(), 'configured_host' => $db->getConfig('host'), 'configured_port' => $db->getConfig('port'), 'configured_database' => $db->getDatabaseName(), 'actual' => $identity],
+ 'schema' => ['public_table_count' => count($tables), 'missing_foundation' => array_values(array_diff($required, $tables)), 'existing_budget_tables' => array_values(array_intersect($budget, $tables)), 'existing_integration_tables' => array_values(array_intersect($internal, $tables)), 'budget_ui_support_tables_present' => array_values(array_intersect(['cb_ui_commands', 'cb_consumption_sources'], $tables)), 'permission_column_length' => $column?->character_maximum_length, 'scope_constraint_present' => $constraint !== null, 'scope_constraint_sha256' => $constraint ? hash('sha256', $constraint->definition) : null, 'ddl_privileges' => $privileges],
+ 'database_roles' => $roles, 'config_cached' => $app->configurationIsCached(),
+ 'owner_capabilities' => $ownerCapabilities,
+ 'preservation_baseline' => $preserve, 'organisation_counts' => $counts, 'purchasing_pointer' => json_decode(file_get_contents($host.'/packages/purchasing/current.json'), true, 16, JSON_THROW_ON_ERROR), 'pointer_sha256' => hash_file('sha256', $host.'/packages/purchasing/current.json'),
+ 'bundle_exists' => is_dir($host.'/packages/internal-requisitions'), 'maintenance_marker_present' => is_file($host.'/storage/framework/down'), 'operational_config_file_present' => is_file($host.'/config/purchasing-operational.php'), 'read_only_transaction' => true, 'installed' => false];
+ $db->rollBack();
+ echo json_encode($state, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
+} catch (Throwable $error) {
+ if ($db && $db->transactionLevel() > 0) {
+ $db->rollBack();
+ }fwrite(STDERR, 'Read-only inventory failed: '.get_class($error)."\n");
+ exit(1);
+}
diff --git a/scripts/sgf-internal-install.php b/scripts/sgf-internal-install.php
index 0714169..8984763 100644
--- a/scripts/sgf-internal-install.php
+++ b/scripts/sgf-internal-install.php
@@ -6,11 +6,12 @@ use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
use Tempo\Purchasing\Delivery\Installation;
+use Tempo\Purchasing\Delivery\SqlPrivileges;
$root = dirname(__DIR__);
$action = $argv[1] ?? '';
$host = realpath($argv[2] ?? '');
-if (! in_array($action, ['plan', 'migrate'], true) || ! $host) {
+if (! in_array($action, ['plan', 'migrate', 'privileges-plan'], true) || ! $host) {
fwrite(STDERR, "Usage: plan | migrate \n");
exit(1);
}
@@ -21,7 +22,7 @@ try {
throw new RuntimeException('Manifesto de preparação operacional obrigatório.');
}
foreach ($manifest['support_files'] as $file => $hash) {
- if (($file !== 'bootstrap.php' && ! preg_match('~^(app|packages|integrations|scripts)/[A-Za-z0-9_./-]+$~D', $file)) || str_contains($file, '..') || is_link($root.'/'.$file) || ! hash_equals($hash, hash_file('sha256', $root.'/'.$file))) {
+ if (($file !== 'bootstrap.php' && ! preg_match('~^((app|packages|integrations|scripts)/[A-Za-z0-9_./-]+|resources/js/(app|PurchasingHome)\.jsx|public/build/manifest\.json)$~D', $file)) || str_contains($file, '..') || is_link($root.'/'.$file) || ! hash_equals($hash, hash_file('sha256', $root.'/'.$file))) {
throw new RuntimeException('Integridade da entrega divergente.');
}
}
@@ -36,7 +37,9 @@ try {
$app->make(Kernel::class)->bootstrap();
require $root.'/integrations/sgf/operational/bootstrap.php';
$db = DB::connection();
- if ($action === 'plan') {
+ if ($action === 'privileges-plan') {
+ echo json_encode(SqlPrivileges::plan($argv[3] ?? ''), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
+ } elseif ($action === 'plan') {
echo json_encode(['target_sha256' => Installation::target($db), 'manifest_sha256' => hash('sha256', $manifestText), 'schema_prepared' => $db->getSchemaBuilder()->hasTable('sgc_installations'), 'activated' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
} else {
$backup = $argv[4] ?? '';
diff --git a/scripts/sgf-read-8039-cycle.ps1 b/scripts/sgf-read-8039-cycle.ps1
new file mode 100644
index 0000000..20ed8dc
--- /dev/null
+++ b/scripts/sgf-read-8039-cycle.ps1
@@ -0,0 +1,16 @@
+param([ValidateSet('apply','rollback','reapply')][string]$Action)
+$ErrorActionPreference='Stop'
+$demoRoot='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/development-app'
+$demoPhp='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe'
+$demoProcesses=Get-CimInstance Win32_Process | Where-Object {$_.Name -eq 'php.exe' -and $_.CommandLine -match '\s-S\s+127\.0\.0\.1:8039\s'}
+foreach($demoProcess in $demoProcesses){
+ if($demoProcess.ExecutablePath -ne $demoPhp.Replace('/','\') -or $demoProcess.CommandLine -notmatch '-t public\s+scripts/router\.php'){throw 'Unexpected 8039 process'}
+ Stop-Process -Id $demoProcess.ProcessId
+}
+$operationOutput=& $demoPhp C:/laragon/www/compras/scripts/sgf-read-8039.php $Action
+if($LASTEXITCODE -ne 0){throw 'Update/recovery failed; keep 8039 stopped and inspect private journal'}
+$operationResult=($operationOutput -join "`n") | ConvertFrom-Json
+if(-not $operationResult.deployment_id){throw 'Operation did not return a deployment receipt; retain stopped state'}
+$operationOutput
+$logRoot='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/read-correction-recovery'
+Start-Process -FilePath $demoPhp -ArgumentList @('-S','127.0.0.1:8039','-t','public','scripts/router.php') -WorkingDirectory $demoRoot -WindowStyle Hidden -RedirectStandardOutput "$logRoot/$Action-out.log" -RedirectStandardError "$logRoot/$Action-error.log"
diff --git a/scripts/sgf-read-8039-http.mjs b/scripts/sgf-read-8039-http.mjs
new file mode 100644
index 0000000..010cc77
--- /dev/null
+++ b/scripts/sgf-read-8039-http.mjs
@@ -0,0 +1,49 @@
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+const legacy=process.argv.includes('--previous');
+const privateData=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json','utf8'));
+assert.deepEqual([privateData.tenant_id,privateData.company_id,privateData.unit_id],[1,1,1]);
+const checks=[];
+for(const profile of privateData.profiles){
+ const jar=new Map();let csrf='',token='';
+ const api=async(path,method='GET',body,status=200,overrides={})=>{
+ const response=await fetch('http://127.0.0.1:8039'+path,{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,'X-Purchasing-Context':'procurement.internal','Idempotency-Key':crypto.randomUUID(),...overrides},...(body===undefined?{}:{body:JSON.stringify(body)})});
+ for(const cookie of response.headers.getSetCookie()){const pair=cookie.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}
+ const text=await response.text();let d;try{d=JSON.parse(text);}catch{d={};}
+ assert((Array.isArray(status)?status:[status]).includes(response.status),`${profile.role} ${path}: expected ${status}, got ${response.status}: ${d.message??''}`);
+ if(d.csrf)csrf=d.csrf;if(d.context?.token)token=d.context.token;
+ return d;
+ };
+ await api('/api/session');await api('/api/login','POST',{email:profile.email,password:privateData.password});
+ await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1,module_id:'purchasing'});
+ const list=await api('/api/internal-requisitions');
+ if(legacy&&profile.role==='consulta'){
+ assert(!list.records.some(r=>r.id===1));await api('/api/internal-requisitions/1','GET',undefined,404);
+ checks.push('Previous version reproduces missing read-only listing/detail');continue;
+ }
+ assert(list.records.some(r=>r.id===1),'Existing request must be listed');
+ const detail=await api('/api/internal-requisitions/1');
+ assert.equal(detail.record.id,1);assert.equal(detail.record.version,3);assert.equal(detail.record.status,'approved');assert.equal(detail.record.total_minor,10000);
+ assert.equal(detail.history.length,3);
+ checks.push(`${profile.role}: existing request 1 listed, detail and three-event history readable`);
+ if(profile.role==='consulta'){
+ assert.equal(list.permissions.prepare,false);assert.equal(list.permissions.configure,false);assert.deepEqual(detail.record.allowed_actions,[]);
+ await api('/api/internal-requisitions/save','POST',{operation_key:crypto.randomUUID()},403);
+ for(const action of ['save','submit','decide'])await api('/api/internal-requisitions/1/'+action,'POST',{operation_key:crypto.randomUUID(),version:3,...(action==='decide'?{decision:'approved'}:{})},[403,404]);
+ await api('/api/internal-requisitions/policy','POST',{version:1,reason:'Denied read-only configuration verification.',enabled:true,budget_required:true,first_limit_minor:1000000,second_limit_minor:2000000,approval_includes_tax:true,centres:[{id:1,department_ref:'OPER'}],members:[{user_id:1,department_ref:'OPER'},{user_id:2,department_ref:'OPER'}]},403);
+ await api('/api/internal-requisitions/centres','POST',{operation_key:crypto.randomUUID(),code:'REFUSED',reason:'Read-only denial check.'},403);
+ await api('/api/purchasing-budget/approve','POST',{budget_id:1},403);
+ await api('/api/accesses','GET',undefined,403);
+ checks.push('Consulta: no allowed actions; creation, save, submission, decision, policy, centre, budget approval and administration refused');
+ await api('/api/internal-requisitions/1','GET',undefined,409,{'X-SGF-Context':'invalid'});
+ await api('/api/internal-requisitions/1','GET',undefined,403,{'X-Purchasing-Context':'procurement.third_party'});
+ await api('/api/internal-requisitions/999999999','GET',undefined,404);
+ await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:2,module_id:'purchasing'},403);
+ await api('/api/context','POST',{tenant_id:1,company_id:2,unit_id:3,module_id:'purchasing'},403);
+ checks.push('Consulta: forged context, wrong option, missing record and unauthorised company/unit refused');
+ }
+}
+const report={passed:true,mode:legacy?'previous-version-recovery':'corrected-version',url:'http://127.0.0.1:8039',scope:'1:1:1',request_id:1,request_recreated:false,reservation_commands_executed:false,rights_changed:false,checks,checked_at:new Date().toISOString(),visual_review:false};
+fs.writeFileSync('C:/laragon/www/compras/docs/proofs/sgf-read-8039-'+(legacy?'rollback':'http')+'.json',JSON.stringify(report,null,2));
+console.log(JSON.stringify(report));
diff --git a/scripts/sgf-read-8039.php b/scripts/sgf-read-8039.php
new file mode 100644
index 0000000..2a6ea46
--- /dev/null
+++ b/scripts/sgf-read-8039.php
@@ -0,0 +1,75 @@
+make(Kernel::class)->bootstrap();
+$db=DB::connection();
+$identity=$db->selectOne("select current_database() as db,current_user as role,inet_server_port() as port,current_setting('cluster_name') as cluster");
+if(str_replace('\\','/',$app->basePath())!==$host||$identity->db!=='sgf_modules_dev_2576427d1939'||$identity->role!=='sgf_modules_qa_2576427d1939'||(int)$identity->port!==55439||$identity->cluster!=='sgf_modular_regression_2576427d1939'||config('app.url')!=='http://127.0.0.1:8039')throw new RuntimeException('Wrong demonstration');
+$record=$db->table('ir_requests')->where(['id'=>1,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1])->first();
+if(!$record||$record->status!=='approved'||$record->total_minor!==10000||$record->version!==3||$db->table('cb_budget_entries')->where('kind','reserve')->count()!==1)throw new RuntimeException('Wrong existing request');
+require_once $root.'/integrations/sgf/operational/CodeDeployment.php';
+$deploy=new CodeDeployment($host);
+$fingerprints=function()use($db){
+ return $db->transaction(function()use($db){
+ $db->statement('SET TRANSACTION READ ONLY');$hashes=[];
+ foreach($db->select("select tablename from pg_tables where schemaname='public' order by tablename") as $t){
+ $rows=$db->select('select to_jsonb(t)::text as data from "'.$t->tablename.'" t order by to_jsonb(t)::text');
+ $hashes[$t->tablename]=hash('sha256',json_encode(array_column($rows,'data'),JSON_THROW_ON_ERROR));
+ }return $hashes;
+ });
+};
+$action=$argv[1]??'';
+if($action==='prepare'){
+ if(is_dir($recovery))throw new RuntimeException('Recovery exists; no overwrite');
+ $plan=$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);
+ mkdir($recovery,0700,true);
+ $cfg=$db->getConfig();
+ (new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe','--format=custom','--file='.$recovery.'/before.dump','--dbname='.$identity->db],null,['PGHOST'=>'127.0.0.1','PGPORT'=>'55439','PGUSER'=>$identity->role,'PGPASSWORD'=>$cfg['password']],null,90))->mustRun();
+ (new Process(['C:/Program Files/PostgreSQL/17/bin/pg_restore.exe','--list',$recovery.'/before.dump']))->mustRun();
+ $baseline=['identity'=>$identity,'plan'=>$plan,'data_fingerprints'=>$fingerprints(),'backup_sha256'=>hash_file('sha256',$recovery.'/before.dump'),'pointer_sha256'=>hash_file('sha256',$host.'/packages/purchasing/current.json'),'config_hashes'=>[]];
+ foreach(['.env','config/modules.php','config/purchasing-operational.php','config/purchasing-internal.php','config/purchasing-access.php'] as $file)$baseline['config_hashes'][$file]=hash_file('sha256',$host.'/'.$file);
+ file_put_contents($recovery.'/baseline.json',json_encode($baseline,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ echo json_encode(['prepared'=>true,'tables'=>count($baseline['data_fingerprints']),'backup_sha256'=>$baseline['backup_sha256'],'plan'=>$plan],JSON_PRETTY_PRINT);
+}else{
+ $baseline=json_decode(file_get_contents($recovery.'/baseline.json'),true,64,JSON_THROW_ON_ERROR);
+ $currentFingerprints=$fingerprints();
+ $changedTables=array_keys(array_filter($currentFingerprints,fn($hash,$table)=>$hash!==($baseline['data_fingerprints'][$table]??null),ARRAY_FILTER_USE_BOTH));
+ $auditAppended=false;
+ if(in_array('identity_audit',$changedTables,true)){
+ $extract=new Process(['C:/Program Files/PostgreSQL/17/bin/pg_restore.exe','--data-only','--table=identity_audit','--file=-',$recovery.'/before.dump']);$extract->mustRun();
+ if(!preg_match('~COPY public\.identity_audit \(id,[^)]*\) FROM stdin;\r?\n(.*?)\r?\n\\\\\.~s',$extract->getOutput(),$match))throw new RuntimeException('Audit baseline cannot be reconciled');
+ $ids=array_map(fn($line)=>(int)explode("\t",$line)[0],preg_split('/\r?\n/',$match[1]));
+ $rows=$db->select('select to_jsonb(t)::text as data from identity_audit t where id<=? order by to_jsonb(t)::text',[max($ids)]);
+ if(hash('sha256',json_encode(array_column($rows,'data'),JSON_THROW_ON_ERROR))!==$baseline['data_fingerprints']['identity_audit'])throw new RuntimeException('Existing audit modified');
+ $auditAppended=true;$changedTables=array_values(array_diff($changedTables,['identity_audit']));
+ }
+ if($changedTables!==[])throw new RuntimeException('Database rows changed: '.implode(', ',$changedTables));
+ if(hash_file('sha256',$host.'/packages/purchasing/current.json')!==$baseline['pointer_sha256'])throw new RuntimeException('Pointer changed');
+ foreach($baseline['config_hashes'] as $file=>$hash)if(hash_file('sha256',$host.'/'.$file)!==$hash)throw new RuntimeException('Configuration changed');
+ if($action==='verify'){
+ $report=['passed'=>true,'identity'=>$identity,'data_tables_reconciled'=>count($baseline['data_fingerprints']),'audit_appended_only'=>$auditAppended,'request_id'=>1,'request_version'=>3,'amount_minor'=>10000,'reservation_count'=>1,'rights_and_accounts_unchanged'=>true,'config_unchanged'=>true,'pointer_unchanged'=>true,'candidate_manifest_sha256'=>hash_file('sha256',$host.'/packages/internal-requisitions/manifest.json'),'backup_sha256'=>$baseline['backup_sha256'],'checked_at'=>gmdate('c')];
+ file_put_contents($root.'/docs/proofs/sgf-read-8039-data.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode($report,JSON_PRETTY_PRINT);
+ }else{
+ if(is_file($host.'/storage/framework/down'))throw new RuntimeException('Unexpected maintenance');
+ file_put_contents($host.'/storage/framework/down',json_encode(['time'=>time(),'retry'=>60]));
+ if($action==='rollback'){
+ $applied=json_decode(file_get_contents($recovery.'/applied.json'),true);
+ $result=$deploy->rollback($applied['deployment_id'],'Authorised read fix recovery proof 8039',true);
+ file_put_contents($recovery.'/rollback.json',json_encode($result,JSON_PRETTY_PRINT));
+ }elseif(in_array($action,['apply','reapply'],true)){
+ $plan=$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);
+ $result=$deploy->apply($candidate['directory'],$candidate['archive']['manifest_sha256'],$plan['target_sha256'],'Authorised read-only visibility correction 8039',true);
+ file_put_contents($recovery.'/'.($action==='apply'?'applied':'reapplied').'.json',json_encode($result,JSON_PRETTY_PRINT));
+ }else throw new RuntimeException('Use prepare/apply/rollback/reapply/verify');
+ unlink($host.'/storage/framework/down');echo json_encode($result,JSON_PRETTY_PRINT);
+ }
+}
diff --git a/scripts/sgf-read-candidate.php b/scripts/sgf-read-candidate.php
new file mode 100644
index 0000000..a037078
--- /dev/null
+++ b/scripts/sgf-read-candidate.php
@@ -0,0 +1,31 @@
+$hash){
+ $text=file_get_contents($old.'/'.$path);
+ if(!hash_equals($hash,hash('sha256',$text)))throw new RuntimeException('Prior artifact changed');
+ $payload[$path]=$path===$file?file_get_contents($root.'/'.$file):$text;
+}
+$manifest['support_files'][$file]=hash('sha256',$payload[$file]);
+$manifestText=json_encode($manifest,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
+$id='OPERATIONAL-CANDIDATE-'.substr(hash('sha256',$manifestText),0,16);
+$dir=$root.'/storage/app/private/candidates/'.$id;
+if(file_exists($dir))throw new RuntimeException('Candidate already exists; verify rather than overwrite');
+mkdir($dir,0700,true);
+foreach($payload+['manifest.json'=>$manifestText] as $path=>$text){
+ if(!is_dir(dirname($dir.'/'.$path)))mkdir(dirname($dir.'/'.$path),0700,true);
+ file_put_contents($dir.'/'.$path,$text);
+}
+$archive=$root.'/storage/app/private/candidate-archives/'.$id.'.zip';
+$zip=new ZipArchive;
+if($zip->open($archive,ZipArchive::CREATE|ZipArchive::EXCL)!==true)throw new RuntimeException('Archive collision');
+foreach([...array_keys($payload),'manifest.json'] as $path)$zip->addFile($dir.'/'.$path,$path);
+$zip->close();
+$result=['directory'=>$dir,'archive'=>['file'=>$archive,'sha256'=>hash_file('sha256',$archive),'manifest_sha256'=>hash('sha256',$manifestText)],'prior_manifest_sha256'=>$prior['artifact']['archive']['manifest_sha256'],'changed_files'=>[$file],'schema_changed'=>false,'rights_changed'=>false,'installed_real'=>false];
+file_put_contents($root.'/docs/proofs/sgf-read-candidate.json',json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+echo json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
diff --git a/scripts/sgf-sourcing-candidate.php b/scripts/sgf-sourcing-candidate.php
new file mode 100644
index 0000000..7ab8a55
--- /dev/null
+++ b/scripts/sgf-sourcing-candidate.php
@@ -0,0 +1,24 @@
+$hash){$text=file_get_contents($old['directory'].'/'.$file);if(hash('sha256',$text)!==$hash)throw new RuntimeException('Prior candidate changed');$payload[$file]=$text;}
+foreach(['integrations/sgf/sourcing','packages/purchasing/src'] as $dir)foreach(new DirectoryIterator($root.'/'.$dir) as $file)if($file->isFile())$payload[$dir.'/'.$file->getFilename()]=file_get_contents($file->getPathname());
+foreach(['integrations/sgf/operational/Installation.php','integrations/sgf/operational/CodeDeployment.php','integrations/sgf/access/AccessOverlay.php','integrations/sgf/operational/routes.php','integrations/sgf/internal-requisitions/Controller.php','integrations/sgf/internal-requisitions/screen.mjs'] as $file)$payload[$file]=file_get_contents($root.'/'.$file);
+$payload['bootstrap.php']=file_get_contents($root.'/integrations/sgf/operational/entry.php');
+$payload['scripts/sgf-sourcing-install.php']=file_get_contents($root.'/scripts/sgf-sourcing-install.php');
+require_once $root.'/app/Services/ComparisonPolicy.php';
+$payload['integrations/sgf/sourcing/comparison-defaults.json']=json_encode(App\Services\ComparisonPolicy::DEFAULTS,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
+$file='app/Modules/Identity/Services/Permissions.php';
+$payload[$file]=preg_replace('/public const UNIT = .*?;/s','public const UNIT = '.var_export(['unit.view','unit.edit',...requireRights($root)],true).';',$payload[$file],1);
+$manifest['host_overlays'][$file]['after_sha256']=hash('sha256',$payload[$file]);
+// The same native catalogue and delegation workflow remain in use.
+foreach($payload as $file=>$text)$manifest['support_files'][$file]=hash('sha256',$text);
+$manifestText=json_encode($manifest,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
+$id='OPERATIONAL-CANDIDATE-'.substr(hash('sha256',$manifestText),0,16);$dir=$root.'/storage/app/private/candidates/'.$id;
+if(file_exists($dir))throw new RuntimeException('Immutable candidate exists');mkdir($dir,0700,true);
+foreach($payload+['manifest.json'=>$manifestText] as $file=>$text){if(!is_dir(dirname($dir.'/'.$file)))mkdir(dirname($dir.'/'.$file),0700,true);file_put_contents($dir.'/'.$file,$text);}
+$archive=$root.'/storage/app/private/candidate-archives/'.$id.'.zip';$zip=new ZipArchive;if($zip->open($archive,ZipArchive::CREATE|ZipArchive::EXCL)!==true)throw new RuntimeException('Archive exists');foreach([...array_keys($payload),'manifest.json'] as $file)$zip->addFile($dir.'/'.$file,$file);$zip->close();
+$out=['directory'=>$dir,'archive'=>['file'=>$archive,'sha256'=>hash_file('sha256',$archive),'manifest_sha256'=>hash('sha256',$manifestText)],'prior_manifest_sha256'=>$old['archive']['manifest_sha256'],'schema_extension'=>'sourcing revision 1; four ss_* tables and explicit catalogue extension','real_activated'=>false];file_put_contents($root.'/docs/proofs/sgf-sourcing-candidate.json',json_encode($out,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode($out,JSON_PRETTY_PRINT);
+function requireRights(string $root):array{require_once $root.'/integrations/sgf/access/AccessOverlay.php';return Tempo\Purchasing\Delivery\AccessOverlay::RIGHTS;}
diff --git a/scripts/sgf-sourcing-core-proof.php b/scripts/sgf-sourcing-core-proof.php
new file mode 100644
index 0000000..e66ad10
--- /dev/null
+++ b/scripts/sgf-sourcing-core-proof.php
@@ -0,0 +1,35 @@
+'approved','requester_id'=>1,'data'=>['items'=>[['description'=>'Synthetic material','quantity'=>'1.000','tax_rate'=>'0.00']]]];
+$state=['version'=>0,'suppliers'=>[],'quotes'=>[],'proposals'=>[],'confirmed'=>null];$checks=[];
+$check=function($ok,$label)use(&$checks){if(!$ok)throw new RuntimeException($label);$checks[]=$label;};
+$deny=function($call,$status,$label)use($check){try{$call();throw new RuntimeException('Unexpected success: '.$label);}catch(Tempo\Purchasing\Internal\RequisitionFailure $e){$check($e->status===$status,$label);}};
+$step=function($s,$actor,$action,$input)use($engine,$request,$policy){$next=$engine->transition($s,$request,$actor,$action,$input,$policy,'2026-11-02');$next['version']=$s['version']+1;return $next;};
+foreach([1,2,3] as $id){$state=$step($state,1,'select',['supplier'=>['id'=>$id,'eligible'=>true,'snapshot'=>['name'=>'Synthetic '.$id]]]);$state=$step($state,1,'quote',['supplier_id'=>$id,'reference'=>'Q-'.$id,'currency'=>'MZN','valid_until'=>'2026-12-31','delivery_days'=>5,'payment_terms'=>'30 days','items'=>[['line'=>0,'unit_price'=>(string)(90+$id*10)]]]);}
+$check(!$engine->comparison($state,$policy,'2026-11-02')['ready'],'Existing minimum independent evaluations retained');
+$evaluation=fn($id)=>['supplier_id'=>$id,'quote_revision'=>1,'quality'=>8,'history'=>8,'payment'=>8,'reason'=>'Synthetic evaluation reason'];
+$deny(fn()=>$step($state,1,'evaluate',$evaluation(1)),403,'Requester cannot evaluate');
+foreach([1,2,3] as $id)foreach([2,4] as $actor)$state=$step($state,$actor,'evaluate',$evaluation($id));
+$comparison=$engine->comparison($state,$policy,'2026-11-02');$check($comparison['ready']&&$comparison['top_ids']===[1],'Existing weighted scoring identifies best eligible quote');
+$proposal=['supplier_id'=>1,'comparison_hash'=>$comparison['fingerprint'],'reason'=>'Synthetic award proposal justification','delivery_date'=>'2027-01-15','delivery_location'=>'Local de entrega exclusivamente sintético OPER'];
+$deny(fn()=>$step($state,1,'propose',$proposal),403,'Requester cannot propose award');
+$state=$step($state,2,'propose',$proposal);
+$confirmation=['proposal_index'=>0,'comparison_hash'=>$comparison['fingerprint'],'reason'=>'Independent synthetic confirmation'];
+$deny(fn()=>$step($state,2,'confirm',$confirmation),403,'Proposer cannot confirm own award');
+$deny(fn()=>$step($state,1,'confirm',$confirmation),403,'Requester cannot confirm award');
+$changed=$step($state,1,'quote',['supplier_id'=>1,'reference'=>'Q-1-r2','currency'=>'MZN','valid_until'=>'2026-12-31','delivery_days'=>6,'payment_terms'=>'30 days','items'=>[['line'=>0,'unit_price'=>'100.00']]]);
+$deny(fn()=>$step($changed,4,'confirm',$confirmation),409,'Quote revision invalidates existing award basis and evaluations');
+$revoked=$state;$revoked['quotes'][1]['evaluations'][4]['eligible']=false;
+$deny(fn()=>$step($revoked,4,'confirm',$confirmation),409,'Evaluator revocation invalidates confirmation basis');
+$expired=$state;$expired['quotes'][1]['valid_until']='2026-11-01';
+$deny(fn()=>$step($expired,4,'confirm',$confirmation),409,'Expired quote invalidates confirmation');
+$ok=$step($state,4,'confirm',$confirmation);
+$check($ok['confirmed']['confirmed_by']===4&&$ok['confirmed']['proposal']['proposed_by']===2,'Independent confirmation preserves exact proposed comparison');
+$deny(fn()=>$step($ok,1,'quote',[]),409,'Confirmed process cannot be rewritten');
+$check($state['proposals'][0]['comparison']['fingerprint']===$comparison['fingerprint'],'Comparison snapshot and justification preserved');
+$report=['passed'=>true,'checks'=>$checks,'synthetic_only'=>true,'persistent_demo_accounts_created'=>false,'checked_at'=>gmdate('c')];
+file_put_contents(dirname(__DIR__).'/docs/proofs/sgf-sourcing-core.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode($report,JSON_PRETTY_PRINT);
diff --git a/scripts/sgf-sourcing-demo-apply.ps1 b/scripts/sgf-sourcing-demo-apply.ps1
new file mode 100644
index 0000000..fc7a7d8
--- /dev/null
+++ b/scripts/sgf-sourcing-demo-apply.ps1
@@ -0,0 +1,15 @@
+param([ValidateSet('apply','apply-code')][string]$Action='apply')
+$ErrorActionPreference='Stop'
+$demoRoot='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/development-app'
+$demoPhp='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe'
+foreach($process in (Get-CimInstance Win32_Process | Where-Object {$_.Name -eq 'php.exe' -and $_.CommandLine -match '\s-S\s+127\.0\.0\.1:8039\s'})){
+ if($process.ExecutablePath -ne $demoPhp.Replace('/','\') -or $process.CommandLine -notmatch '-t public\s+scripts/router\.php'){throw 'Unexpected 8039 web process'}
+ Stop-Process -Id $process.ProcessId
+}
+$output=& $demoPhp C:/laragon/www/compras/scripts/sgf-sourcing-demo.php $Action
+if($LASTEXITCODE -ne 0){throw 'Sourcing preparation failed; keep demonstration closed and inspect recovery'}
+$result=($output -join "`n")|ConvertFrom-Json
+if(-not $result.applied){throw 'No valid application receipt'}
+$output
+$logs='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/sourcing-recovery'
+Start-Process -FilePath $demoPhp -ArgumentList @('-S','127.0.0.1:8039','-t','public','scripts/router.php') -WorkingDirectory $demoRoot -WindowStyle Hidden -RedirectStandardOutput "$logs/$Action-server-out.log" -RedirectStandardError "$logs/$Action-server-error.log"
diff --git a/scripts/sgf-sourcing-demo-http.mjs b/scripts/sgf-sourcing-demo-http.mjs
new file mode 100644
index 0000000..a355d80
--- /dev/null
+++ b/scripts/sgf-sourcing-demo-http.mjs
@@ -0,0 +1,26 @@
+import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';
+const privateData=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json','utf8'));
+assert.deepEqual([privateData.tenant_id,privateData.company_id,privateData.unit_id],[1,1,1]);
+const clients={},checks=[];
+for(const p of privateData.profiles){let csrf='',token='';const jar=new Map();
+ const api=async(path,method='GET',body,status=200)=>{const r=await fetch('http://127.0.0.1:8039'+path,{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,'X-Purchasing-Context':'procurement.internal'},...(body===undefined?{}:{body:JSON.stringify(body)})});for(const c of r.headers.getSetCookie()){const pair=c.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}const text=await r.text();let d;try{d=JSON.parse(text);}catch{d={message:text.slice(0,160)};}assert.equal(r.status,status,`${p.role} ${path}: ${d.message??JSON.stringify(d.errors)}`);if(d.csrf)csrf=d.csrf;if(d.context?.token)token=d.context.token;return d;};
+ await api('/api/session');await api('/api/login','POST',{email:p.email,password:privateData.password});await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1,module_id:'purchasing'});clients[p.role]=api;
+}
+const read=who=>clients[who]('/api/sourcing/1');
+async function command(who,action,body,status=200){const current=await read(who);return clients[who]('/api/sourcing/1/'+action,'POST',{operation_key:crypto.randomUUID(),version:current.state.version,...(action==='propose'?{delivery_date:'2027-01-15',delivery_location:'Local de entrega apenas sintético para teste de recusa'}:{}),...body},status);}
+let initial=await read('preparador');assert.equal(initial.request.total_minor,10000);assert.equal(initial.request.version,3);assert.equal(initial.suppliers.length,3);
+for(const [index,supplier] of initial.suppliers.entries()){
+ let current=await read('preparador');if(!current.state.suppliers[supplier.id])await command('preparador','select',{supplier_id:supplier.id});
+ current=await read('preparador');
+ if(!current.state.quotes[supplier.id])await command('preparador','quote',{supplier_id:supplier.id,reference:'DEMO-COTACAO-'+supplier.reference,currency:'MZN',valid_until:'2027-12-31',delivery_days:5+index,payment_terms:'Condições sintéticas: pagamento a 30 dias',items:current.request.data.items.map((_,line)=>({line,unit_price:(100+index*10).toFixed(2)}))});
+ current=await read('revisor');const q=current.state.quotes[supplier.id];
+ if(!q.evaluations[2])await command('revisor','evaluate',{supplier_id:supplier.id,quote_revision:q.revision,quality:8,history:8,payment:8,no_conflict:true,reason:'Avaliação exclusivamente sintética de demonstração; sem contacto externo.'});
+}
+const pending=await read('consulta');assert.equal(pending.comparison.ready,false);assert(pending.comparison.rows.every(r=>r.evaluation_count===1));assert.equal(pending.state.confirmed,null);assert.equal(pending.state.proposals.length,0);
+await command('revisor','propose',{supplier_id:pending.comparison.rows[0].supplier_id,comparison_hash:pending.comparison.fingerprint,reason:'Verificar bloqueio por falta de segundo avaliador independente.'},422);
+await command('revisor','propose',{supplier_id:pending.comparison.rows[1].supplier_id,comparison_hash:pending.comparison.fingerprint,reason:'Verificar sinalização de adaptação orçamental sem movimento financeiro.'},409);
+for(const action of ['select','quote','evaluate','propose','confirm'])await clients.consulta('/api/sourcing/1/'+action,'POST',{},403);
+await clients.revisor('/api/sourcing/1/confirm','POST',{},403);
+for(const who of ['preparador','revisor','consulta']){const origin=await clients[who]('/api/internal-requisitions/1');assert.equal(origin.record.version,3);assert.equal(origin.record.total_minor,10000);assert.equal(origin.history.length,3);assert(origin.canSourcing);await clients[who]('/purchasing/sourcing/1');}
+checks.push('Existing request reused with version 3 and unchanged three-event history');checks.push('Three selected synthetic suppliers, three quotes and one eligible evaluation per quote');checks.push('Comparison and award proposal remain blocked without second eligible evaluator');checks.push('Quoted amount differing from reserved amount is signalled without a budget movement');checks.push('Consulta retains full sourcing read and no preparation/evaluation/proposal/confirmation');checks.push('No persistent identity has award-confirmation right; no award created');checks.push('Protected sourcing screen is accessible to all three existing profiles');
+const proof={passed:true,url:'http://127.0.0.1:8039',scope:'1:1:1',request_id:1,checks,confirmation_completed:false,owner_decision_no_fourth_account:true,visual_review:false,checked_at:new Date().toISOString()};fs.writeFileSync('C:/laragon/www/compras/docs/proofs/sgf-sourcing-demo-http.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));
diff --git a/scripts/sgf-sourcing-demo.php b/scripts/sgf-sourcing-demo.php
new file mode 100644
index 0000000..87594bb
--- /dev/null
+++ b/scripts/sgf-sourcing-demo.php
@@ -0,0 +1,65 @@
+make(Kernel::class)->bootstrap();
+try{
+ $db=DB::connection();$identity=$db->selectOne("select current_database() as db,current_user as role,inet_server_port() as port,current_setting('cluster_name') as cluster");
+ if(str_replace('\\','/',$app->basePath())!==$host||$identity->db!=='sgf_modules_dev_2576427d1939'||$identity->role!=='sgf_modules_qa_2576427d1939'||(int)$identity->port!==55439||$identity->cluster!=='sgf_modular_regression_2576427d1939'||config('app.url')!=='http://127.0.0.1:8039')throw new RuntimeException('Wrong persistent synthetic demonstration');
+ app(Permissions::class)->require(1,1,1,'access.manage');
+ $candidate=json_decode(file_get_contents($root.'/docs/proofs/sgf-sourcing-candidate.json'),true,32,JSON_THROW_ON_ERROR);
+ if(!class_exists(Schema::class))require_once $candidate['directory'].'/integrations/sgf/sourcing/Schema.php';require_once $root.'/integrations/sgf/operational/CodeDeployment.php';
+ $hash=function(string $table)use($db){return hash('sha256',json_encode($db->table($table)->orderBy('id')->get(),JSON_THROW_ON_ERROR));};
+ $action=$argv[1]??'';
+ if($action==='prepare'){
+ if(is_dir($recovery))throw new RuntimeException('Recovery exists: do not overwrite');
+ $proof=json_decode(file_get_contents($root.'/docs/proofs/sgf-sourcing-isolated.json'),true);if(!$proof['passed']||!$proof['persistent_demo_unchanged']||!($proof['recovery']['data_schema_restore_verified']??false)||$proof['setup']['deployment']['deployment_id']!==substr($candidate['archive']['manifest_sha256'],0,24))throw new RuntimeException('Current candidate needs accepted isolation/recovery proof');
+ if($db->table('users')->count()!==3)throw new RuntimeException('Unexpected accounts: reconcile before proceeding');
+ mkdir($recovery,0700,true);$cfg=$db->getConfig();(new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe','--format=custom','--file='.$recovery.'/before.dump','--dbname='.$identity->db],null,['PGHOST'=>'127.0.0.1','PGPORT'=>'55439','PGUSER'=>$identity->role,'PGPASSWORD'=>$cfg['password']],null,90))->mustRun();
+ (new Process(['C:/Program Files/PostgreSQL/17/bin/pg_restore.exe','--list',$recovery.'/before.dump']))->mustRun();
+ $baseline=['candidate'=>$candidate,'identity'=>$identity,'backup_sha256'=>hash_file('sha256',$recovery.'/before.dump'),'users'=>$hash('users'),'requests'=>$hash('ir_requests'),'budget'=>$hash('cb_budget_entries'),'consumption'=>$hash('cb_budget_consumptions'),'grants'=>$db->table('permission_grants')->orderBy('id')->get()->all(),'pointer_sha256'=>hash_file('sha256',$host.'/packages/purchasing/current.json')];
+ file_put_contents($recovery.'/baseline.json',json_encode($baseline,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['prepared'=>true,'identity'=>$identity,'backup_sha256'=>$baseline['backup_sha256']]);
+ }elseif($action==='apply'){
+ $baseline=json_decode(file_get_contents($recovery.'/baseline.json'),true,64,JSON_THROW_ON_ERROR);
+ foreach(['users'=>'users','requests'=>'ir_requests','budget'=>'cb_budget_entries','consumption'=>'cb_budget_consumptions'] as $key=>$table)if($baseline[$key]!==$hash($table))throw new RuntimeException('Baseline changed: '.$table);
+ if($baseline['candidate']['archive']['manifest_sha256']!==$candidate['archive']['manifest_sha256']||!hash_equals($baseline['backup_sha256'],hash_file('sha256',$recovery.'/before.dump')))throw new RuntimeException('Candidate/backup changed');
+ if(is_file($host.'/storage/framework/down'))throw new RuntimeException('Unexpected prior maintenance');file_put_contents($host.'/storage/framework/down','{}');
+ $old=json_decode(file_get_contents($host.'/packages/internal-requisitions/manifest.json'),true);$current=json_decode(file_get_contents($host.'/storage/app/private/module-deployments/current.json'),true);$next=json_decode(file_get_contents($candidate['directory'].'/manifest.json'),true);
+ $migration=Schema::migrate($db,$baseline['backup_sha256'],'Owner-confirmed synthetic sourcing 8039');
+ $receipt=['host'=>$host,'from_manifest_sha256'=>$current['manifest_sha256'],'to_manifest_sha256'=>$candidate['archive']['manifest_sha256'],'schema_verified'=>Schema::compatible($db),'changes'=>[]];
+ foreach($next['support_files'] as $file=>$sha)if(($old['support_files'][$file]??null)!==$sha)$receipt['changes'][$file]=['before'=>$old['support_files'][$file]??null,'after'=>$sha];
+ file_put_contents($host.'/storage/app/private/module-deployments/schema-transition-'.$candidate['archive']['manifest_sha256'].'.json',json_encode($receipt));
+ $deploy=new CodeDeployment($host);$plan=$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);$applied=$deploy->apply($candidate['directory'],$candidate['archive']['manifest_sha256'],$plan['target_sha256'],'Owner-confirmed synthetic sourcing 8039',true);file_put_contents($recovery.'/applied.json',json_encode($applied,JSON_PRETTY_PRINT));
+ $db->transaction(function()use($db){
+ foreach([1=>['purchasing_sourcing.prepare'],2=>['purchasing_sourcing.evaluate','purchasing_sourcing.propose']] as $id=>$rights)foreach($rights as $right){
+ $scope=['user_id'=>$id,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$right];$inserted=$db->table('permission_grants')->insertOrIgnore($scope+['created_at'=>now(),'updated_at'=>now()]);
+ if($inserted)Audit::record('access.granted',1,1,1,1,$id,details:['permission'=>$right,'reason'=>'Owner explicitly authorised exact sourcing map; no fourth persistent account.']);
+ }
+ foreach(['A','B','C'] as $letter)$db->table('ss_suppliers')->insertOrIgnore(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'reference'=>'SOURCING-DEMO-'.$letter,'payload'=>json_encode(['name'=>'Fornecedor sintético '.$letter,'active'=>true,'blocked'=>false,'review_until'=>'2027-12-31','version'=>1,'synthetic_only'=>true]),'created_at'=>now()]);
+ });
+ file_put_contents($host.'/config/purchasing-sourcing.php','true];');unlink($host.'/storage/framework/down');echo json_encode(['applied'=>true,'deployment'=>$applied,'schema'=>$migration,'persistent_accounts_created'=>false,'grants_added'=>3,'confirmation_blocked_without_independent_identity'=>true]);
+ }elseif($action==='apply-code'){
+ if(!Schema::compatible($db))throw new RuntimeException('Code-only update requires the existing compatible sourcing schema');
+ $proof=json_decode(file_get_contents($root.'/docs/proofs/sgf-sourcing-isolated.json'),true);if(!$proof['passed']||$proof['setup']['deployment']['deployment_id']!==substr($candidate['archive']['manifest_sha256'],0,24))throw new RuntimeException('Candidate proof mismatch');
+ $before=[];foreach([...Schema::TABLES,'ir_requests','cb_budget_entries','cb_budget_consumptions','permission_grants','users'] as $table)$before[$table]=$hash($table);
+ if(is_file($host.'/storage/framework/down'))throw new RuntimeException('Unexpected prior maintenance');
+ $deploy=new CodeDeployment($host);$plan=$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);
+ file_put_contents($recovery.'/before-final-code.json',json_encode(['candidate'=>$candidate,'data_hashes'=>$before,'plan'=>$plan],JSON_PRETTY_PRINT));file_put_contents($host.'/storage/framework/down','{}');
+ $applied=$deploy->apply($candidate['directory'],$candidate['archive']['manifest_sha256'],$plan['target_sha256'],'Authorised sourcing final code refinement',true);
+ foreach($before as $table=>$expected)if($hash($table)!==$expected)throw new RuntimeException('Code update changed data: '.$table);
+ file_put_contents($recovery.'/final-code.json',json_encode($applied,JSON_PRETTY_PRINT));unlink($host.'/storage/framework/down');echo json_encode(['applied'=>true,'code_only'=>true,'deployment'=>$applied,'data_preserved'=>true,'schema_changed'=>false,'rights_changed'=>false]);
+ }elseif($action==='verify'){
+ if(hash_file('sha256',$host.'/packages/internal-requisitions/manifest.json')!==$candidate['archive']['manifest_sha256'])throw new RuntimeException('Installed candidate does not match the delivery report');
+ $baseline=json_decode(file_get_contents($recovery.'/baseline.json'),true,64,JSON_THROW_ON_ERROR);foreach(['users'=>'users','requests'=>'ir_requests','budget'=>'cb_budget_entries','consumption'=>'cb_budget_consumptions'] as $key=>$table)if($baseline[$key]!==$hash($table))throw new RuntimeException('Protected baseline changed: '.$table);
+ $current=$db->table('permission_grants')->orderBy('id')->get()->keyBy('id');foreach($baseline['grants'] as $old)if((array)$current[$old['id']]!=$old)throw new RuntimeException('Previous grant changed');
+ $new=$current->except(array_column($baseline['grants'],'id'))->values()->all();if(count($new)!==3)throw new RuntimeException('Expected exactly three authorised new rights');foreach($new as $g)if([$g->tenant_id,$g->company_id,$g->unit_id]!==[1,1,1]||!in_array([$g->user_id,$g->permission],[[1,'purchasing_sourcing.prepare'],[2,'purchasing_sourcing.evaluate'],[2,'purchasing_sourcing.propose']],true))throw new RuntimeException('Unapproved grant');
+ if(hash_file('sha256',$host.'/packages/purchasing/current.json')!==$baseline['pointer_sha256'])throw new RuntimeException('Pointer changed');
+ $process=$db->table('ss_processes')->where(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'request_id'=>1])->first();$state=json_decode($process->payload,true);if($state['confirmed']!==null||$state['proposals']!==[])throw new RuntimeException('Incomplete comparison must not be awarded');
+ $report=['passed'=>true,'identity'=>$identity,'scope'=>'1:1:1','candidate'=>$candidate,'protected_users_requisition_reservation_consumption_unchanged'=>true,'previous_grants_preserved'=>true,'new_grants'=>$new,'persistent_accounts_created'=>false,'proposals'=>0,'confirmed'=>false,'suppliers_selected'=>count($state['suppliers']),'quotations'=>count($state['quotes']),'pending'=>'Second eligible evaluator and independent confirmer absent; no account created by owner decision.','backup_sha256'=>$baseline['backup_sha256'],'recovery_directory'=>$recovery,'checked_at'=>gmdate('c')];file_put_contents($root.'/docs/proofs/sgf-sourcing-demo.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode($report,JSON_PRETTY_PRINT);
+ }else throw new RuntimeException('Use prepare/apply/verify');
+}catch(Throwable $e){fwrite(STDERR,$e->getMessage()."\n");exit(1);}
diff --git a/scripts/sgf-sourcing-install.php b/scripts/sgf-sourcing-install.php
new file mode 100644
index 0000000..0460237
--- /dev/null
+++ b/scripts/sgf-sourcing-install.php
@@ -0,0 +1,31 @@
+ | migrate ');
+ $host=str_replace('\\','/',$host);$manifestText=file_get_contents($root.'/manifest.json');$manifest=json_decode($manifestText,true,64,JSON_THROW_ON_ERROR);$manifestHash=hash('sha256',$manifestText);
+ foreach($manifest['support_files'] as $file=>$hash)if(str_contains($file,'..')||!preg_match('~^[A-Za-z0-9_./-]+$~D',$file)||is_link($root.'/'.$file)||!hash_equals($hash,hash_file('sha256',$root.'/'.$file)))throw new RuntimeException('Bundle integrity failed');
+ require $host.'/vendor/autoload.php';$app=require $host.'/bootstrap/app.php';$app->make(Kernel::class)->bootstrap();
+ if(!class_exists(Installation::class))require_once $root.'/integrations/sgf/operational/bootstrap.php';
+ if(!class_exists(Schema::class))require_once $root.'/integrations/sgf/sourcing/Schema.php';
+ require_once $root.'/integrations/sgf/operational/CodeDeployment.php';
+ $db=DB::connection();$current=json_decode(file_get_contents($host.'/storage/app/private/module-deployments/current.json'),true,32,JSON_THROW_ON_ERROR);
+ (new CodeDeployment($host))->plan($host.'/packages/internal-requisitions',$current['manifest_sha256']);
+ $plan=['target_sha256'=>Installation::target($db),'manifest_sha256'=>$manifestHash,'schema_changes'=>Schema::TABLES,'new_rights'=>Schema::RIGHTS,'grant_performed'=>false,'activated'=>false];
+ if($action==='plan'){echo json_encode($plan,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);exit;}
+ $backup=$argv[4]??'';$backupHash=$argv[5]??'';$operator=$argv[6]??'';
+ if(!hash_equals($plan['target_sha256'],$argv[3]??'')||!hash_equals($manifestHash,$argv[7]??'')||!is_file($backup)||!hash_equals($backupHash,hash_file('sha256',$backup))||trim($operator)===''||!is_file($host.'/storage/framework/down'))throw new RuntimeException('Target, manifest, backup, operator and maintenance are required');
+ (new Process([getenv('PG_RESTORE_BINARY')?:'pg_restore','--list',$backup],null,null,null,60))->mustRun();
+ $result=Schema::migrate($db,$backupHash,$operator);
+ $old=json_decode(file_get_contents($host.'/packages/internal-requisitions/manifest.json'),true,64,JSON_THROW_ON_ERROR);
+ $receipt=['host'=>$host,'from_manifest_sha256'=>$current['manifest_sha256'],'to_manifest_sha256'=>$manifestHash,'schema_verified'=>Schema::compatible($db),'changes'=>[]];
+ foreach($manifest['support_files'] as $file=>$hash)if(($old['support_files'][$file]??null)!==$hash)$receipt['changes'][$file]=['before'=>$old['support_files'][$file]??null,'after'=>$hash];
+ file_put_contents($host.'/storage/app/private/module-deployments/schema-transition-'.$manifestHash.'.json',json_encode($receipt,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ echo json_encode($result+['activated'=>false,'rights_granted'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
+}catch(Throwable $e){fwrite(STDERR,$e->getMessage()."\n");exit(1);}
diff --git a/scripts/sgf-sourcing-isolated.mjs b/scripts/sgf-sourcing-isolated.mjs
new file mode 100644
index 0000000..4ba786c
--- /dev/null
+++ b/scripts/sgf-sourcing-isolated.mjs
@@ -0,0 +1,54 @@
+import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync,spawn} from 'node:child_process';import net from 'node:net';
+const repo='C:/laragon/www/compras',parent='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939',source=parent+'/development-app';
+const suffix=crypto.randomBytes(6).toString('hex'),database='sgf_sourcing_test_'+suffix,work=parent+'/private/sourcing-'+suffix,clone=work+'/app';
+const pg='C:/Program Files/PostgreSQL/17/bin',php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';fs.mkdirSync(work,{recursive:true});
+const envText=fs.readFileSync(source+'/.env','utf8');const env=Object.fromEntries([...envText.matchAll(/^([A-Z_]+)=(.*)$/gm)].map(m=>[m[1],m[2].trim().replace(/^['"]|['"]$/g,'')]));
+if(env.DB_DATABASE!=='sgf_modules_dev_2576427d1939'||env.DB_PORT!=='55439')throw Error('Wrong demonstration');
+const admin=fs.readFileSync(parent+'/private/admin-password.txt','utf8');
+function run(exe,args,options={}){const r=spawnSync(exe,args,{windowsHide:true,encoding:'utf8',maxBuffer:16*1024*1024,...options});if(r.status!==0)throw Error((r.stdout??'')+(r.stderr??''));return r.stdout;}
+const adminEnv={...process.env,PGPASSWORD:admin};const qaEnv={...process.env,PGPASSWORD:env.DB_PASSWORD};
+const fingerprintSQL="BEGIN READ ONLY;SELECT md5(coalesce(jsonb_agg(to_jsonb(t) ORDER BY id)::text,'')) FROM ir_requests t;SELECT md5(coalesce(jsonb_agg(to_jsonb(t) ORDER BY id)::text,'')) FROM cb_budget_entries t;SELECT md5(coalesce(jsonb_agg(to_jsonb(t) ORDER BY id)::text,'')) FROM users t;SELECT md5(coalesce(jsonb_agg(to_jsonb(t) ORDER BY id)::text,'')) FROM permission_grants t;COMMIT;";
+const sourceFingerprint=(db=env.DB_DATABASE)=>run(pg+'/psql.exe',['-X','-h','127.0.0.1','-p','55439','-U',env.DB_USERNAME,'-d',db,'-At','-v','ON_ERROR_STOP=1','-c',fingerprintSQL],{env:qaEnv});
+const before=sourceFingerprint();let created=false,server;
+try{
+ const archivedBaseline=parent+'/private/sourcing-recovery/before.dump',useArchivedBaseline=fs.existsSync(archivedBaseline);
+ if(useArchivedBaseline)fs.copyFileSync(archivedBaseline,work+'/before.dump');else run(pg+'/pg_dump.exe',['-h','127.0.0.1','-p','55439','-U',env.DB_USERNAME,'-d',env.DB_DATABASE,'-Fc','-f',work+'/before.dump'],{env:qaEnv});
+ const cluster=run(pg+'/psql.exe',['-X','-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-At','-c',"select current_setting('cluster_name')"],{env:adminEnv}).trim();if(cluster!=='sgf_modular_regression_2576427d1939')throw Error('Wrong cluster');
+ run(pg+'/psql.exe',['-X','-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1','-c',`CREATE DATABASE ${database} OWNER sgf_modules_qa_2576427d1939`],{env:adminEnv});created=true;
+ run(pg+'/pg_restore.exe',['-h','127.0.0.1','-p','55439','-U',env.DB_USERNAME,'-d',database,'--no-owner','--no-privileges','--exit-on-error',work+'/before.dump'],{env:qaEnv});
+ const copyBaselineFingerprint=sourceFingerprint(database);
+ fs.mkdirSync(clone,{recursive:true});
+ for(const dir of ['app','config','routes','bootstrap','scripts','packages','resources','public'])fs.cpSync(source+'/'+dir,clone+'/'+dir,{recursive:true});
+ const guard=clone+'/app/Shared/Testing/TestingDatabaseGuard.php';fs.writeFileSync(guard,fs.readFileSync(guard,'utf8').replaceAll('sgf_modules_dev_2576427d1939',database)); // Exact physical identity adaptation; guards remain enabled.
+ for(const file of ['artisan','composer.json','composer.lock'])fs.copyFileSync(source+'/'+file,clone+'/'+file);fs.cpSync(source+'/storage/app/private/module-deployments',clone+'/storage/app/private/module-deployments',{recursive:true});
+ for(const d of ['storage/framework/sessions','storage/framework/views','storage/framework/cache/data','storage/logs'])fs.mkdirSync(clone+'/'+d,{recursive:true});
+ fs.cpSync(source+'/vendor',clone+'/vendor',{recursive:true}); // Independent autoloader and dependencies; never mutate the demo vendor.
+ for(const dir of fs.readdirSync(clone+'/storage/app/private/module-deployments',{withFileTypes:true})){if(!dir.isDirectory())continue;const file=clone+'/storage/app/private/module-deployments/'+dir.name+'/journal.json';if(fs.existsSync(file)){const j=JSON.parse(fs.readFileSync(file));j.host_identity=crypto.createHash('sha256').update(clone).digest('hex');fs.writeFileSync(file,JSON.stringify(j,null,2));}}
+ if(useArchivedBaseline){
+ const expectedBaseline=JSON.parse(fs.readFileSync(repo+'/docs/proofs/sgf-read-candidate.json','utf8')).archive.manifest_sha256;let hops=0;fs.writeFileSync(clone+'/storage/framework/down','{}');
+ while(true){const current=JSON.parse(fs.readFileSync(clone+'/storage/app/private/module-deployments/current.json','utf8'));if(current.manifest_sha256===expectedBaseline)break;if(++hops>4)throw Error('Unexpected baseline ancestry');const rolled=JSON.parse(run(php,[repo+'/scripts/sgf-code-deploy.php','rollback',clone,current.deployment_id,'Disposable reproduction of pre-sourcing baseline','--offline'],{cwd:clone}));if(rolled.state!=='rolled-back')throw Error('Failed to reproduce prior code');}fs.unlinkSync(clone+'/storage/framework/down');
+ }
+ fs.writeFileSync(clone+'/.env',envText.replace(/^DB_DATABASE=.*$/m,'DB_DATABASE='+database).replace(/^APP_ENV=.*$/m,'APP_ENV=testing').replace(/^APP_DEBUG=.*$/m,'APP_DEBUG=false'));
+ const vars={...process.env,SOURCING_CLONE:clone,SOURCING_DB:database,SOURCING_WORK:work,SOURCING_REPO:repo,APP_BASE_PATH:clone};
+ const setup=run(php,[repo+'/tests/Support/sgf-sourcing-copy.php','setup'],{cwd:clone,env:vars});
+ fs.writeFileSync(work+'/setup-output.txt',setup);let receipt;try{receipt=JSON.parse(setup);}catch{throw Error('Setup failed; private diagnostic '+work+'/setup-output.txt');}if(!receipt.passed)throw Error('Setup proof did not pass');
+ const listener=net.createServer();await new Promise(r=>listener.listen(0,'127.0.0.1',r));const port=listener.address().port;await new Promise(r=>listener.close(r));
+ server=spawn(php,['-S','127.0.0.1:'+port,'-t','public','scripts/router.php'],{cwd:clone,env:vars,windowsHide:true,stdio:['ignore',fs.openSync(work+'/web-out.log','a'),fs.openSync(work+'/web-error.log','a')]});
+ await new Promise((resolve,reject)=>{let tries=0;const poll=()=>{const s=net.connect(port,'127.0.0.1');s.once('connect',()=>{s.destroy();resolve();});s.once('error',()=>{s.destroy();if(++tries>50)reject(Error('Test web host unavailable'));else setTimeout(poll,100);});};poll();});
+ const http=run(process.execPath,[repo+'/tests/Support/sgf-sourcing-http.mjs'],{cwd:repo,env:{...vars,SOURCING_PORT:String(port)}});fs.writeFileSync(work+'/http-output.txt',http);
+ const result=JSON.parse(http);if(!result.passed)throw Error('HTTP test failed');
+ server.kill();await new Promise(r=>setTimeout(r,200));server=null;
+ const recovered=JSON.parse(run(php,[repo+'/tests/Support/sgf-sourcing-copy.php','verify'],{cwd:clone,env:vars}));
+ // Data/schema recovery is separate from the successful code rollback above.
+ run(pg+'/pg_dump.exe',['-h','127.0.0.1','-p','55439','-U',env.DB_USERNAME,'-d',database,'-Fc','-f',work+'/confirmed-before-recovery.dump'],{env:qaEnv});
+ run(pg+'/psql.exe',['-X','-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1','-c',`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${database}' AND pid<>pg_backend_pid()`,'-c',`DROP DATABASE ${database}`,'-c',`CREATE DATABASE ${database} OWNER sgf_modules_qa_2576427d1939`],{env:adminEnv});
+ run(pg+'/pg_restore.exe',['-h','127.0.0.1','-p','55439','-U',env.DB_USERNAME,'-d',database,'--no-owner','--no-privileges','--exit-on-error',work+'/before.dump'],{env:qaEnv});
+ if(sourceFingerprint(database)!==copyBaselineFingerprint)throw Error('Restored source request, reservation, accounts or grants diverged');
+ const schemaRestored=run(pg+'/psql.exe',['-X','-h','127.0.0.1','-p','55439','-U',env.DB_USERNAME,'-d',database,'-At','-c',"SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename LIKE 'ss_%'; SELECT count(*) FROM sgc_installations WHERE module='sourcing'"],{env:qaEnv}).trim();if(schemaRestored!=='0\n0'&&schemaRestored!=='0\r\n0')throw Error('Schema recovery diverged');
+ recovered.data_schema_restore_verified=true;recovered.pre_recovery_state_archived=true;recovered.restored_requests_reservations_accounts_grants_match=true;
+ const proof={passed:true,database,work,setup:receipt,http:result,recovery:recovered,persistent_demo_unchanged:before===sourceFingerprint(),synthetic_copy_only:true,checked_at:new Date().toISOString()};if(!proof.persistent_demo_unchanged)throw Error('Original demo changed');
+ fs.writeFileSync(repo+'/docs/proofs/sgf-sourcing-isolated.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));
+}finally{
+ if(server)server.kill();
+ if(created)run(pg+'/psql.exe',['-X','-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1','-c',`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${database}' AND pid<>pg_backend_pid()`,'-c',`DROP DATABASE ${database}`],{env:adminEnv});
+}
diff --git a/scripts/sgf-update-cache-proof.php b/scripts/sgf-update-cache-proof.php
new file mode 100644
index 0000000..937615d
--- /dev/null
+++ b/scripts/sgf-update-cache-proof.php
@@ -0,0 +1,191 @@
+getMessage(), $text), $text);
+ }
+};
+$apply = function (CodeDeployment $deployment, string $bundle, string $hash, ?callable $checkpoint = null) {
+ $plan = $deployment->plan($bundle, $hash);
+
+ return $deployment->apply($bundle, $hash, $plan['target_sha256'], 'Synthetic version operator', true, $checkpoint);
+};
+$copy = updateFixture($host, $manifest);
+$d = new CodeDeployment($copy);
+$v1 = $apply($d, $old, $oldHash);
+$unowned = updateFixture($host, $manifest);
+$copyBundle($old, $unowned.'/packages/internal-requisitions');
+$deny(fn () => (new CodeDeployment($unowned))->plan($new, $newHash), 'sem proveniência');
+$mutant = sys_get_temp_dir().'/sgf-update-schema-'.bin2hex(random_bytes(6));
+$copyBundle($new, $mutant);
+$changed = json_decode(file_get_contents($mutant.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
+$driver = 'integrations/sgf/operational/Installation.php';
+file_put_contents($mutant.'/'.$driver, file_get_contents($mutant.'/'.$driver)."\n// Synthetic schema-driver revision\n");
+$changed['support_files'][$driver] = hash_file('sha256', $mutant.'/'.$driver);
+file_put_contents($mutant.'/manifest.json', json_encode($changed, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+$deny(fn () => $d->plan($mutant, hash_file('sha256', $mutant.'/manifest.json')), 'Transição de schema');
+$plan = $d->plan($new, $newHash);
+$check($plan['previous']['deployment_id'] === $v1['deployment_id'], 'plan binds update to active installation');
+$v2 = $apply($d, $new, $newHash);
+$check(hash_file('sha256', $copy.'/packages/internal-requisitions/manifest.json') === $newHash, 'new version applied without source connection');
+$check(is_dir($copy.'/storage/app/private/module-deployments/'.$v2['deployment_id'].'/previous-bundle'), 'previous version preserved locally');
+$deny(fn () => $d->rollback($v1['deployment_id'], 'Synthetic out-of-order', true), 'fora de ordem');
+$deny(fn () => $d->apply($old, $oldHash, $plan['target_sha256'], 'Synthetic stale replay', true), 'versão activa');
+$file = array_key_first($manifest['host_overlays']);
+$content = file_get_contents($copy.'/'.$file);
+file_put_contents($copy.'/'.$file, $content."\n// local work\n");
+$deny(fn () => $d->plan($new, $newHash), 'Código aplicado alterado');
+file_put_contents($copy.'/'.$file, $content);
+file_put_contents($copy.'/packages/internal-requisitions/local-extension.php', ' $d->plan($new, $newHash), 'Conteúdo extra');
+unlink($copy.'/packages/internal-requisitions/local-extension.php');
+$d->rollback($v2['deployment_id'], 'Synthetic version rollback', true);
+$check(hash_file('sha256', $copy.'/packages/internal-requisitions/manifest.json') === $oldHash, 'rollback restores previous bundle exactly');
+$check(json_decode(file_get_contents($copy.'/storage/app/private/module-deployments/current.json'), true)['deployment_id'] === $v1['deployment_id'], 'rollback restores active provenance');
+$retryPlan = $d->plan($new, $newHash);
+$check($retryPlan['deployment_id'] !== $v2['deployment_id'], 'new attempt preserves rolled-back journal');
+$deny(fn () => $apply($d, $new, $newHash, function ($step) {
+ if ($step === 'previous-bundle-preserved') {
+ throw new RuntimeException('Injected update switch failure');
+ }
+}), 'Injected update switch failure');
+$d->rollback($retryPlan['deployment_id'], 'Synthetic interrupted transition', true);
+$check(hash_file('sha256', $copy.'/packages/internal-requisitions/manifest.json') === $oldHash, 'interrupted transition recovers previous version');
+$again = $apply($d, $new, $newHash);
+$check($again['deployment_id'] !== $retryPlan['deployment_id'], 'reapply creates a separate audited attempt');
+$d->rollback($again['deployment_id'], 'Synthetic clean transition rollback', true);
+$d->rollback($v1['deployment_id'], 'Synthetic first installation rollback', true);
+$check(! is_dir($copy.'/packages/internal-requisitions'), 'complete rollback restores original host composition');
+
+$native = updateFixture($host, $manifest, true);
+$nativeDeploy = new CodeDeployment($native);
+$environment = ['APP_ENV' => 'production', 'APP_DEBUG' => 'false', 'APP_BASE_PATH' => $native, 'APP_KEY' => 'base64:'.base64_encode(random_bytes(32)), 'DB_CONNECTION' => 'sqlite', 'DB_DATABASE' => ':memory:', 'DB_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'file', 'QUEUE_CONNECTION' => 'sync'];
+$previousEnv = [];
+foreach ($environment as $key => $value) {
+ $previousEnv[$key] = getenv($key);
+ putenv($key.'='.$value);
+}
+$server = null;
+$start = function () use ($native) {
+ $socket = stream_socket_server('tcp://127.0.0.1:0');
+ $name = stream_socket_get_name($socket, false);
+ fclose($socket);
+ $port = (int) substr(strrchr($name, ':'), 1);
+ $process = proc_open([PHP_BINARY, '-S', '127.0.0.1:'.$port, '-t', $native.'/public'], [0 => ['pipe', 'r'], 1 => ['file', $native.'/storage/logs/server.log', 'a'], 2 => ['file', $native.'/storage/logs/server.log', 'a']], $pipes, $native);
+ fclose($pipes[0]);
+ for ($i = 0; $i < 100; $i++) {
+ $probe = @fsockopen('127.0.0.1', $port);
+ if ($probe) {
+ fclose($probe);
+ break;
+ }usleep(50000);
+ }
+
+ return [$process, $port];
+};
+$stop = function ($process) {
+ proc_terminate($process);
+ proc_close($process);
+};
+$status = function ($port, $path) {
+ $context = stream_context_create(['http' => ['ignore_errors' => true, 'header' => "Accept: application/json\r\n", 'timeout' => 10]]);
+ file_get_contents('http://127.0.0.1:'.$port.$path, false, $context);
+ preg_match('/\s([0-9]{3})\s/', $http_response_header[0], $match);
+
+ return (int) $match[1];
+};
+try {
+ $a = $apply($nativeDeploy, $old, $oldHash);
+ $nativeDeploy->rebuildCaches($a['deployment_id'], 'Synthetic native cache V1', true);
+ $check(is_file($native.'/bootstrap/cache/config.php') && is_file($native.'/bootstrap/cache/routes-v7.php') && is_file($native.'/bootstrap/cache/events.php'), 'native Laravel config route and event caches built');
+ $b = $apply($nativeDeploy, $new, $newHash);
+ $check(! is_file($native.'/bootstrap/cache/config.php') && ! is_file($native.'/bootstrap/cache/routes-v7.php'), 'version update invalidates previous native caches');
+ $deny(fn () => $nativeDeploy->rebuildCaches($b['deployment_id'], 'Synthetic cache compilation failure', true, function ($command) {
+ if ($command === 'config:cache') {
+ throw new RuntimeException('Injected cache compilation interruption');
+ }
+ }), 'Injected cache compilation interruption');
+ $cacheJournal = json_decode(file_get_contents($native.'/storage/app/private/module-deployments/'.$b['deployment_id'].'/journal.json'), true, 32, JSON_THROW_ON_ERROR);
+ $check($cacheJournal['cache_state'] === 'failed' && is_file($native.'/storage/framework/down'), 'partial native cache build is journalled under maintenance');
+ file_put_contents($native.'/bootstrap/cache/config.php', ' $nativeDeploy->rollback($b['deployment_id'], 'Synthetic conflicting cache rollback', true), 'Cache mudou após compilação');
+ file_put_contents($native.'/bootstrap/cache/config.php', $compiled);
+ $nativeDeploy->rollback($b['deployment_id'], 'Synthetic cached version rollback', true);
+ $check(! is_file($native.'/bootstrap/cache/config.php') && ! is_file($native.'/bootstrap/cache/routes-v7.php'), 'rollback invalidates registered rebuilt caches');
+ $nativeDeploy->rebuildCaches($a['deployment_id'], 'Synthetic native cache restored V1', true);
+ $previousCacheHash = hash_file('sha256', $native.'/bootstrap/cache/config.php');
+ $replayedRollback = $nativeDeploy->rollback($b['deployment_id'], 'Synthetic repeated version rollback', true);
+ $check($replayedRollback['replayed'] && hash_file('sha256', $native.'/bootstrap/cache/config.php') === $previousCacheHash, 'repeated rollback preserves previous-version rebuilt caches');
+ [$server,$port] = $start();
+ $check($status($port, '/purchasing/internal-requisitions') === 503, 'previous version restarts with native caches and maintenance');
+ $stop($server);
+ $server = null;
+ $nativeDeploy->rollback($a['deployment_id'], 'Synthetic native baseline rollback', true);
+ unlink($native.'/storage/framework/down');
+ [$server,$port] = $start();
+ $check($status($port, '/purchasing/internal-requisitions') === 404, 'restarted baseline exposes no removed module route');
+ $check($status($port, '/up') === 200, 'baseline starts after cached deployment rollback');
+} finally {
+ if (is_resource($server)) {
+ $stop($server);
+ }foreach ($previousEnv as $key => $value) {
+ putenv($value === false ? $key : $key.'='.$value);
+ }
+}
+$check(file_get_contents($host.'/packages/purchasing/current.json') === $pointer, 'destination remains untouched on 0.1.1');
+$proof = ['passed' => true, 'checks' => $checks, 'old_candidate' => $old, 'new_candidate' => $new, 'archive' => $prepared['archive'], 'copies' => [$copy, $native, $unowned, $mutant], 'checked_at' => gmdate('c'), 'destination_version' => '0.1.1', 'destination_applied' => false, 'prior_suites_repeated' => false, 'actual_destination_services_touched' => false, 'native_caches_built' => true, 'isolated_server_restarts_verified' => true];
+file_put_contents($root.'/docs/proofs/sgf-update-cache-delivery.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+echo json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n";
diff --git a/scripts/supply-install.php b/scripts/supply-install.php
index 8655d4f..8c18e00 100644
--- a/scripts/supply-install.php
+++ b/scripts/supply-install.php
@@ -35,7 +35,9 @@ $contextsInstall = in_array('--contexts', $argv ?? [], true);
$noticesInstall = in_array('--notices', $argv ?? [], true);
$warehouseInstall = in_array('--warehouse-drafts', $argv ?? [], true);
$fulfilmentInstall = in_array('--warehouse-events', $argv ?? [], true);
-if ((int) $lotInstall + (int) $countInstall + (int) $contextsInstall + (int) $noticesInstall + (int) $warehouseInstall + (int) $fulfilmentInstall > 1) {
+$reservationInstall = in_array('--warehouse-reservations', $argv ?? [], true);
+$recoveryInstall = in_array('--replenishment-recovery', $argv ?? [], true);
+if ((int) $lotInstall + (int) $countInstall + (int) $contextsInstall + (int) $noticesInstall + (int) $warehouseInstall + (int) $fulfilmentInstall + (int) $reservationInstall + (int) $recoveryInstall > 1) {
throw new LogicException('Seleccione uma única migração por instalação.');
}
$migration = $contextsInstall ? '2026_11_01_000045_add_work_context_configuration' : ($countInstall ? '2026_11_01_000044_allow_signed_lot_adjustments' : ($lotInstall ? '2026_11_01_000043_create_inventory_lot_allocations' : '2026_11_01_000042_create_supply_records'));
@@ -52,6 +54,14 @@ if ($fulfilmentInstall) {
$migration = '2026_11_01_000048_create_warehouse_request_events';
$newTable = 'warehouse_request_events';
}
+if ($reservationInstall) {
+ $migration = '2026_11_02_000049_create_warehouse_reservations';
+ $newTable = 'warehouse_reservations';
+}
+if ($recoveryInstall) {
+ $migration = '2026_11_02_000050_create_replenishment_work';
+ $newTable = 'replenishment_work';
+}
if ($connection->table('migrations')->where('migration', $migration)->exists()) {
echo "A migração de fornecimento já está instalada; nenhum registo alterado.\n";
exit;
@@ -115,6 +125,12 @@ try {
$proof['commercial_policy_activated'] = false;
}
$proofName = $fulfilmentInstall ? 'warehouse-fulfilment-install' : ($warehouseInstall ? 'warehouse-drafts-install' : ($noticesInstall ? 'workflow-notices-install' : ($contextsInstall ? 'work-context-install' : ($countInstall ? 'inventory-count-install' : ($lotInstall ? 'inventory-lot-install' : 'supply-install')))));
+ if ($reservationInstall) {
+ $proofName = 'warehouse-reservations-install';
+ }
+ if ($recoveryInstall) {
+ $proofName = 'replenishment-recovery-install';
+ }
file_put_contents(dirname(__DIR__).'/docs/proofs/'.$proofName.'.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo 'Migração dirigida instalada; restauro conferido, '.count($preserved)." tabelas preservadas. Sem atribuições ou dados de negócio.\n";
} finally {
diff --git a/scripts/warehouse-exchange-http.mjs b/scripts/warehouse-exchange-http.mjs
new file mode 100644
index 0000000..45ebef1
--- /dev/null
+++ b/scripts/warehouse-exchange-http.mjs
@@ -0,0 +1,32 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const viewer = client();
+ await viewer.enter('admin');
+ const dashboard = await viewer.request('/dashboard');
+ if (dashboard.page?.props.context?.enforced) {
+ await viewer.request('/contexto', 'POST', { area: 'procurement', option: 'procurement.internal' });
+ }
+ const first = await viewer.request('/stock/integracao/entregas?limit=1', 'GET', undefined, false);
+ assert.equal(first.status, 200);
+ const data = JSON.parse(first.text);
+ assert.equal(data.schema, 'sgc.warehouse.fulfilment-feed.v1');
+ assert.ok(Array.isArray(data.items) && data.items.length <= 1);
+ assert.equal(typeof data.has_more, 'boolean');
+ assert.equal(typeof data.next_cursor, 'string');
+ const next = await viewer.request('/stock/integracao/entregas?cursor=' + encodeURIComponent(data.next_cursor), 'GET', undefined, false);
+ assert.equal(next.status, 200);
+ assert.equal(JSON.parse(next.text).schema, data.schema);
+ const invalid = await viewer.request('/stock/integracao/entregas?limit=101');
+ assert.equal(invalid.status, 422);
+ await fs.writeFile('docs/proofs/warehouse-exchange-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, result: 'passed',
+ cursor_roundtrip: true, limit_validated: true, observed_first_page_items: data.items.length,
+ business_commands: false, accounts_or_grants_changed: false,
+ sgf_installation_changed: false, visual_review: false
+ }, null, 2));
+ console.log('Consulta de integração do armazém e cursor conferidos em HTTP.');
+` + '})()')(fs, assert);
diff --git a/scripts/warehouse-http-evaluation.php b/scripts/warehouse-http-evaluation.php
new file mode 100644
index 0000000..12de898
--- /dev/null
+++ b/scripts/warehouse-http-evaluation.php
@@ -0,0 +1,45 @@
+exec($sql);
+ }
+ $source = new WarehouseHttpSource('http://127.0.0.1:8005', $input['tenant_id'], fn () => $input['cookie'], allowLoopbackHttp: true);
+ $inbox = new WarehouseInbox($db, 'synthetic-qa-8005', $input['tenant_id'], 99, 'disposable-evaluation');
+ $result = (new WarehousePull($inbox))->run($source, maxPages: 1, pageSize: 1);
+ $checkpoint = $inbox->checkpoint();
+ $next = $source($checkpoint['cursor'], 1);
+ if ($next['schema'] !== 'sgc.warehouse.fulfilment-feed.v1') {
+ throw new RuntimeException('Contrato de avaliação inesperado.');
+ }
+ $anonymousDenied = false;
+ try {
+ $anonymous = new WarehouseHttpSource('http://127.0.0.1:8005', $input['tenant_id'], fn () => 'SGC_TEST_SESSION=not-authenticated', allowLoopbackHttp: true);
+ $anonymous(null, 1);
+ } catch (WarehouseTransportFailure $e) {
+ $anonymousDenied = $e->category === 'authentication';
+ }
+ if (! $anonymousDenied) {
+ throw new RuntimeException('Acesso anónimo não recusado como esperado.');
+ }
+ echo json_encode(['result' => 'passed', 'transport' => 'native PHP cURL to existing QA session', 'pages' => $result['pages'],
+ 'received_facts' => $result['inserted'], 'receiver_checkpoint_version' => $checkpoint['version'],
+ 'cursor_roundtrip' => true, 'unauthenticated_read_denied' => true, 'receiver' => 'disposable SQLite memory',
+ 'persistent_business_commands' => false, 'persistent_accounts_or_grants_changed' => false, 'sgf_installation_changed' => false], JSON_THROW_ON_ERROR);
+} catch (Throwable) {
+ fwrite(STDERR, 'A avaliação HTTP do conector não foi aprovada. Nenhuma credencial é apresentada.'.PHP_EOL);
+ exit(1);
+}
diff --git a/scripts/warehouse-http-source-http.mjs b/scripts/warehouse-http-source-http.mjs
new file mode 100644
index 0000000..b64fdfa
--- /dev/null
+++ b/scripts/warehouse-http-source-http.mjs
@@ -0,0 +1,42 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const original = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+const common = original.replace(' request,', ` request,
+ cookieHeader: () => [...cookies].map(([k, v]) => k + '=' + v).join('; '),`);
+assert.notEqual(common, original, 'Expected existing QA cookie helper');
+
+await new Function('fs', 'assert', 'spawn', 'return (async()=>{' + common + `
+ const viewer = client();
+ await viewer.enter('admin');
+ const dashboard = await viewer.request('/dashboard');
+ if (dashboard.page?.props.context?.enforced || dashboard.page?.props.workContext?.enforced) {
+ await viewer.request('/contexto', 'POST', { area: 'procurement', option: 'procurement.internal' });
+ }
+ const tenant = dashboard.page?.props.auth?.user?.tenant_id;
+ assert.ok(Number.isSafeInteger(tenant) && tenant > 0);
+ const result = await new Promise((resolve, reject) => {
+ const child = spawn('php', ['scripts/warehouse-http-evaluation.php'], {
+ cwd: process.cwd(), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000
+ });
+ let output = '';
+ child.stdout.on('data', chunk => { output += chunk; });
+ child.stderr.resume();
+ child.on('error', () => reject(new Error('Não foi possível iniciar o ensaio do conector.')));
+ child.on('close', code => {
+ if (code !== 0) return reject(new Error('A avaliação HTTP do conector não foi aprovada.'));
+ try { resolve(JSON.parse(output)); } catch { reject(new Error('Resultado de avaliação inválido.')); }
+ });
+ // Session travels only over the private child stdin, never in arguments or proof files.
+ child.stdin.end(JSON.stringify({ tenant_id: tenant, cookie: viewer.cookieHeader() }));
+ });
+ assert.equal(result.result, 'passed');
+ assert.equal(result.unauthenticated_read_denied, true);
+ await fs.writeFile('docs/proofs/warehouse-http-source-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, ...result, credentials_recorded: false,
+ technical_credential_issued: false, visual_review: false
+ }, null, 2));
+ console.log('Cliente HTTP autenticado e receptor descartável conferidos contra a avaliação8005.');
+` + '})()')(fs, assert, spawn);
diff --git a/scripts/warehouse-pilot-readiness-http.mjs b/scripts/warehouse-pilot-readiness-http.mjs
new file mode 100644
index 0000000..8584b69
--- /dev/null
+++ b/scripts/warehouse-pilot-readiness-http.mjs
@@ -0,0 +1,13 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+const source=await fs.readFile('scripts/supplier-evaluation-check.mjs','utf8');
+const common=source.slice(source.indexOf('const base ='),source.indexOf('const author ='));
+await new Function('fs','assert','return (async()=>{'+common+`
+ const viewer=client();await viewer.enter('admin');
+ const dashboard=await viewer.request('/dashboard');
+ if(dashboard.page?.props.context?.enforced)await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'});
+ const response=await viewer.request('/stock/piloto');assert.equal(response.status,200);assert.equal(response.page.component,'stock/pilot');
+ const p=response.page.props;assert.equal(p.syntheticEvaluation,true);assert.ok(p.report.checks.length>=6);assert.ok(Array.isArray(p.report.registered));assert.ok(Array.isArray(p.report.participants.prepare));assert.equal(typeof p.report.warehouse_scenario_ready,'boolean');
+ await fs.writeFile('docs/proofs/warehouse-pilot-readiness-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,component:'stock/pilot',synthetic_evaluation:true,registered_count:p.report.registered.length,warehouse_scenario_ready:p.report.warehouse_scenario_ready,replenishment_scenario_ready:p.report.replenishment_scenario_ready,client_configuration_complete:p.report.client_configuration_complete,checks:p.report.checks.map(c=>({key:c.key,ok:c.ok})),accounts_or_grants_changed:false,business_commands:false,visual_browser_review:false},null,2));
+ console.log('Diagnóstico do piloto conferido por HTTP sem alterar configurações ou executar compras.');
+ `+'})()')(fs,assert);
diff --git a/scripts/warehouse-workdesk-http.mjs b/scripts/warehouse-workdesk-http.mjs
new file mode 100644
index 0000000..4e42499
--- /dev/null
+++ b/scripts/warehouse-workdesk-http.mjs
@@ -0,0 +1,17 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+const source=await fs.readFile('scripts/supplier-evaluation-check.mjs','utf8');
+const common=source.slice(source.indexOf('const base ='),source.indexOf('const author ='));
+await new Function('fs','assert','return (async()=>{'+common+`
+ const viewer=client();await viewer.enter('admin');
+ const dashboard=await viewer.request('/dashboard');
+ if(dashboard.page?.props.context?.enforced)await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'});
+ const response=await viewer.request('/stock/requisicoes');assert.equal(response.status,200);
+ const p=response.page.props;assert.equal(response.page.component,'stock/requests');assert.equal(p.ready,true);assert.equal(p.canPrepare,false);
+ assert.ok(p.views.some(v=>v.key==='all'));assert.ok(Array.isArray(p.records.data));assert.equal(typeof p.records.total,'number');assert.ok(p.checkedAt);
+ for(const view of ['review','reserve','delivery','receipt']){const r=await viewer.request('/stock/requisicoes?view='+view);assert.equal(r.status,200);assert.equal(r.page.props.records.total,0);}
+ const settings=await viewer.request('/administracao/parametrizacao');assert.equal(settings.status,200);
+ assert.ok(settings.page.props.workflowTasks.warehouse_request_prepare);assert.ok(settings.page.props.workflowTasks.warehouse_request_review);assert.ok(settings.page.props.workflowTasks.warehouse_request_deliver);
+ await fs.writeFile('docs/proofs/warehouse-workdesk-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,component:'stock/requests',read_only_administrator:true,task_views_enforce_permissions:true,filters_and_pagination:true,profile_tasks_present:true,accounts_or_grants_changed:false,business_commands:false,visual_browser_review:false},null,2));
+ console.log('Acompanhamento de armazém: consulta, tarefas por competência e paginação conferidos em HTTP.');
+ `+'})()')(fs,assert);
diff --git a/scripts/work-context-pg-check.php b/scripts/work-context-pg-check.php
index aa0f14a..89ed2b1 100644
--- a/scripts/work-context-pg-check.php
+++ b/scripts/work-context-pg-check.php
@@ -18,21 +18,76 @@ config(['database.connections.context_proof_admin' => [...$config, 'database' =>
$admin = DB::connection('context_proof_admin');
$admin->statement('CREATE DATABASE "'.$database.'"');
$workflow = in_array('--workflow', $argv ?? [], true);
-$warehouse = in_array('--warehouse', $argv ?? [], true);
+$pilot = in_array('--pilot', $argv ?? [], true);
+$workdesk = in_array('--workdesk', $argv ?? [], true);
+$readiness = in_array('--readiness', $argv ?? [], true);
+$exchange = in_array('--exchange', $argv ?? [], true);
+$transport = in_array('--transport', $argv ?? [], true);
+$internalTrace = in_array('--internal-trace', $argv ?? [], true);
+$taskInbox = in_array('--task-inbox', $argv ?? [], true);
+$financeIntegration = in_array('--finance-integration', $argv ?? [], true);
+$awardTasks = in_array('--award-tasks', $argv ?? [], true);
+$quotationTasks = in_array('--quotation-tasks', $argv ?? [], true);
+$organizationPreview = in_array('--organization-preview', $argv ?? [], true);
+$pilotAccessProposal = in_array('--pilot-access-proposal', $argv ?? [], true);
+$formalTasks = in_array('--formal-tasks', $argv ?? [], true);
+$inbox = in_array('--inbox', $argv ?? [], true) || $transport;
+$warehouse = in_array('--warehouse', $argv ?? [], true) || $pilot || $workdesk || $readiness;
try {
$environment = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'],
'DB_DATABASE' => $database, 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password']];
$tests = $workflow ? ['tests/Feature/InternalRequisitionCandidateTest.php', 'tests/Feature/WorkContextTest.php'] : ['tests/Feature/WorkContextTest.php'];
if ($warehouse) {
- $tests = ['tests/Feature/WarehouseRequestsTest.php', 'tests/Feature/WarehouseAvailabilityTest.php', 'tests/Feature/WarehouseFulfilmentTest.php'];
+ $tests = ['tests/Feature/WarehouseRequestsTest.php', 'tests/Feature/WarehouseAvailabilityTest.php', 'tests/Feature/WarehouseFulfilmentTest.php', 'tests/Feature/AutomaticPurchasesTest.php'];
}
- $process = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', ...$tests], dirname(__DIR__), $environment, timeout: 120);
+ if ($readiness) {
+ $tests = [...$tests, 'tests/Feature/WarehousePilotReadinessTest.php', 'tests/Feature/WarehousePilotJourneyTest.php'];
+ }
+ if ($exchange) {
+ $tests = ['tests/Feature/WarehouseExchangeTest.php'];
+ }
+ if ($inbox) {
+ $environment['INBOX_TEST_PG'] = '1';
+ $tests = ['tests/Unit/WarehouseInboxTest.php', 'tests/Feature/WarehouseExchangeTest.php'];
+ }
+ if ($transport) {
+ $tests[] = 'tests/Unit/WarehouseHttpSourceTest.php';
+ }
+ if ($internalTrace) {
+ $tests = ['tests/Feature/InternalSupplyTraceTest.php', 'tests/Feature/WarehousePilotJourneyTest.php'];
+ }
+ if ($taskInbox) {
+ $tests = ['tests/Feature/WorkflowTaskInboxTest.php', 'tests/Feature/InternalSupplyTraceTest.php', 'tests/Feature/WarehousePilotJourneyTest.php'];
+ }
+ if ($financeIntegration) {
+ $tests = ['tests/Feature/FinanceIntegrationTest.php', 'tests/Feature/PaymentIntegrationTest.php', 'tests/Feature/InvoiceTest.php', 'tests/Feature/ExternalPaymentTest.php', 'tests/Feature/PurchaseProcessTest.php', 'tests/Feature/PayablePaymentDecisionTest.php'];
+ }
+ if ($awardTasks) {
+ $tests = ['tests/Feature/PurchaseAwardOrderTest.php'];
+ }
+ if ($quotationTasks) {
+ $tests = ['tests/Feature/SupplierQuotationTest.php', 'tests/Feature/PurchaseAwardOrderTest.php'];
+ }
+ if ($organizationPreview) {
+ $tests = ['tests/Feature/OrganizationActivationPreviewTest.php', 'tests/Feature/WorkContextTest.php'];
+ }
+ if ($pilotAccessProposal) {
+ $tests = ['tests/Feature/PilotAccessProposalTest.php'];
+ }
+ if ($formalTasks) {
+ $tests = ['tests/Feature/FormalAdmissionTest.php', 'tests/Feature/FormalRecommendationTest.php', 'tests/Feature/DocumentaryQualificationTest.php'];
+ }
+ $process = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', ...$tests, ...($formalTasks ? ['--filter=formal task'] : ($quotationTasks ? ['--filter=quotation tasks'] : ($awardTasks ? ['--filter=task inbox'] : [])))], dirname(__DIR__), $environment, timeout: $financeIntegration || $awardTasks || $quotationTasks || $organizationPreview || $pilotAccessProposal || $formalTasks ? 300 : 120);
$process->run();
echo $process->getOutput();
if (! $process->isSuccessful()) {
throw new RuntimeException('Prova PostgreSQL de contexto não aprovada.');
}
$races = [];
+ if ($inbox) {
+ require __DIR__.'/../tests/Support/inbox-pg-races.php';
+ $races = inboxPgRaces($environment, $config, $database);
+ }
if ($warehouse) {
config(['database.connections.warehouse_proof_read' => [...$config, 'database' => $database, 'url' => null]]);
$reader = DB::connection('warehouse_proof_read');
@@ -75,9 +130,101 @@ try {
}
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'result' => 'passed'];
}
+ $fixture = new Process([PHP_BINARY, 'tests/Concurrency/replenishment-worker.php', 'prepare'], dirname(__DIR__), $environment, timeout: 30);
+ $fixture->mustRun();
+ $data = json_decode($fixture->getOutput(), true, 32, JSON_THROW_ON_ERROR);
+ $first = new Process([PHP_BINARY, 'tests/Concurrency/replenishment-worker.php', 'hold', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 30);
+ $first->start();
+ $deadline = microtime(true) + 5;
+ while ($first->isRunning() && ! str_contains($first->getOutput(), 'locked') && microtime(true) < $deadline) {
+ usleep(20000);
+ }
+ if (! str_contains($first->getOutput(), 'locked')) {
+ throw new RuntimeException('Falta bloqueio da compra automática.');
+ }
+ $second = new Process([PHP_BINARY, 'tests/Concurrency/replenishment-worker.php', 'run', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 30);
+ $second->start();
+ $blocked = false;
+ $deadline = microtime(true) + 5;
+ while ($first->isRunning() && microtime(true) < $deadline) {
+ $blocked = (bool) $reader->selectOne("select exists(select 1 from pg_stat_activity where datname=? and wait_event_type='Lock') as blocked", [$database])->blocked;
+ if ($blocked) {
+ break;
+ }usleep(20000);
+ }
+ $first->wait();
+ $second->wait();
+ if (! $first->isSuccessful() || ! $second->isSuccessful()) {
+ throw new RuntimeException('Falha na activação concorrente: '.$first->getErrorOutput().$second->getErrorOutput());
+ }
+ $a = json_decode(trim(str_replace('locked', '', $first->getOutput())), true, 32, JSON_THROW_ON_ERROR);
+ $b = json_decode(trim($second->getOutput()), true, 32, JSON_THROW_ON_ERROR);
+ if (! $blocked || $a['rule'] !== $b['rule'] || count($a['purchases']) !== 1 || $a['purchases'] !== $b['purchases'] || (int) $reader->table('requisitions')->where('tenant_id', $data['tenant'])->where('status', 'draft')->count() !== 1) {
+ throw new RuntimeException('A reposição concorrente duplicou o rascunho.');
+ }
+ $races[] = ['scenario' => 'automatic_purchase_activation_retry', 'real_lock_contention' => true, 'single_draft' => true, 'result' => 'passed'];
+ if ($pilot) {
+ $fixture = new Process([PHP_BINARY, 'tests/Concurrency/recovery-worker.php', 'prepare'], dirname(__DIR__), $environment, timeout: 30);
+ $fixture->mustRun();
+ $data = json_decode($fixture->getOutput(), true, 32, JSON_THROW_ON_ERROR);
+ $before = $reader->table('replenishment_work')->where('id', $data['work'])->sole();
+ if ($before->generation <= $before->processed_generation || $reader->table('requisitions')->where('tenant_id', $data['tenant'])->where('status', 'draft')->exists()) {
+ throw new RuntimeException('A prova não deixou sinal durável sem rascunho após interrupção.');
+ }
+ $first = new Process([PHP_BINARY, 'tests/Concurrency/recovery-worker.php', 'hold', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 30);
+ $first->start();
+ $deadline = microtime(true) + 5;
+ while ($first->isRunning() && ! str_contains($first->getOutput(), 'locked') && microtime(true) < $deadline) {
+ usleep(20000);
+ }
+ if (! str_contains($first->getOutput(), 'locked')) {
+ throw new RuntimeException('Primeiro recuperador sem bloqueio.');
+ }
+ $second = new Process([PHP_BINARY, 'tests/Concurrency/recovery-worker.php', 'run', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 30);
+ $second->start();
+ $blocked = false;
+ $deadline = microtime(true) + 5;
+ while ($first->isRunning() && microtime(true) < $deadline) {
+ $blocked = (bool) $reader->selectOne("select exists(select 1 from pg_stat_activity where datname=? and wait_event_type='Lock') as blocked", [$database])->blocked;
+ if ($blocked) {
+ break;
+ }usleep(20000);
+ }
+ $first->wait();
+ $second->wait();
+ if (! $first->isSuccessful() || ! $second->isSuccessful()) {
+ throw new RuntimeException('Recuperação concorrente falhou.');
+ }
+ $a = json_decode(trim(str_replace('locked', '', $first->getOutput())), true, 32, JSON_THROW_ON_ERROR);
+ $b = json_decode(trim($second->getOutput()), true, 32, JSON_THROW_ON_ERROR);
+ $after = $reader->table('replenishment_work')->where('id', $data['work'])->sole();
+ if (! $blocked || $a['result'] !== 'saved' || $b['result'] !== 'saved' || $a['authenticated'] || $b['authenticated'] || count($a['drafts']) !== 1 || $a['drafts'] !== $b['drafts'] || $after->generation !== $after->processed_generation) {
+ throw new RuntimeException('A recuperação perdeu ou duplicou a compra.');
+ }
+ $races[] = ['scenario' => 'abrupt_exit_after_physical_commit_then_two_recovery_workers', 'real_lock_contention' => true, 'single_draft' => true, 'no_authenticated_human' => true, 'result' => 'passed'];
+ }
DB::purge('warehouse_proof_read');
}
- file_put_contents(dirname(__DIR__).'/docs/proofs/'.($warehouse ? 'warehouse-drafts-pg' : ($workflow ? 'workflow-notices-pg' : 'work-context-pg')).'.json', json_encode(['checked_at' => gmdate('c'), 'database' => 'disposable PostgreSQL', 'result' => trim($process->getOutput()), 'races' => $races, 'persistent_data_changed' => false, 'database_removed_in_finally' => true], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+ $proofName = match (true) {
+ $formalTasks => 'formal-task-integration-pg',
+ $pilotAccessProposal => 'pilot-access-proposal-pg',
+ $organizationPreview => 'organization-activation-preview-pg',
+ $quotationTasks => 'quotation-task-integration-pg',
+ $awardTasks => 'award-task-integration-pg',
+ $financeIntegration => 'finance-integration-pg',
+ $taskInbox => 'workflow-task-inbox-pg',
+ $internalTrace => 'internal-supply-integration-pg',
+ $transport => 'warehouse-http-source-pg',
+ $inbox => 'warehouse-inbox-pg',
+ $exchange => 'warehouse-exchange-pg',
+ $readiness => 'warehouse-pilot-readiness-pg',
+ $workdesk => 'warehouse-workdesk-pg',
+ $pilot => 'replenishment-pilot-pg',
+ $warehouse => 'warehouse-drafts-pg',
+ $workflow => 'workflow-notices-pg',
+ default => 'work-context-pg',
+ };
+ file_put_contents(dirname(__DIR__).'/docs/proofs/'.$proofName.'.json', json_encode(['checked_at' => gmdate('c'), 'database' => 'disposable PostgreSQL', 'result' => trim($process->getOutput()), 'races' => $races, 'persistent_data_changed' => false, 'database_removed_in_finally' => true], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
} finally {
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$database]);
$admin->statement('DROP DATABASE "'.$database.'"');
diff --git a/scripts/workflow-task-inbox-http.mjs b/scripts/workflow-task-inbox-http.mjs
new file mode 100644
index 0000000..55012df
--- /dev/null
+++ b/scripts/workflow-task-inbox-http.mjs
@@ -0,0 +1,33 @@
+import fs from 'node:fs/promises';
+import assert from 'node:assert/strict';
+
+const source = await fs.readFile('scripts/supplier-evaluation-check.mjs', 'utf8');
+const common = source.slice(source.indexOf('const base ='), source.indexOf('const author ='));
+await new Function('fs', 'assert', 'return (async()=>{' + common + `
+ const viewer = client();
+ await viewer.enter('admin');
+ const dashboard = await viewer.request('/dashboard');
+ const context = dashboard.page?.props.workContext ?? dashboard.page?.props.context;
+ const routes = [];
+ for (const [area, key, path] of [['procurement', 'procurement.internal', '/requisicoes/tarefas'], ['people', 'people.internal', '/pessoas/tarefas']]) {
+ if (context?.enforced && !context.available?.includes(key)) {
+ routes.push({ area, result: 'not_available_in_existing_scope' });
+ continue;
+ }
+ if (context?.enforced) {
+ assert.equal((await viewer.request('/contexto', 'POST', { area, option: key })).status, 302);
+ }
+ const response = await viewer.request(path);
+ assert.equal(response.status, 200);
+ assert.equal(response.page.props.area, area);
+ assert.ok(Array.isArray(response.page.props.tasks.data));
+ assert.ok(response.page.props.tasks.data.length <= 25);
+ assert.ok(response.page.props.tasks.total >= response.page.props.tasks.data.length);
+ routes.push({ area, result: 'passed', total: response.page.props.tasks.total, shown: response.page.props.tasks.data.length });
+ }
+ await fs.writeFile('docs/proofs/workflow-task-inbox-http.json', JSON.stringify({
+ checked_at: new Date().toISOString(), environment: base, routes, existing_account_used: true,
+ business_commands: false, accounts_or_grants_changed: false, sgf_installation_changed: false, visual_review: false
+ }, null, 2));
+ console.log('Listas de tarefas por área conferidas com o âmbito existente.');
+` + '})()')(fs, assert);
diff --git a/storage/framework/replenishment-evaluation.lock b/storage/framework/replenishment-evaluation.lock
new file mode 100644
index 0000000..e69de29
diff --git a/storage/framework/replenishment-status.json b/storage/framework/replenishment-status.json
new file mode 100644
index 0000000..7b6ec6a
--- /dev/null
+++ b/storage/framework/replenishment-status.json
@@ -0,0 +1,8 @@
+{
+ "started_at": "2026-11-02T18:26:48+00:00",
+ "finished_at": "2026-11-02T18:26:50+00:00",
+ "pid": 24528,
+ "scope": "SGC evaluation 8005",
+ "result": "passed",
+ "exit_code": 0
+}
\ No newline at end of file
diff --git a/tests/Concurrency/inbox-worker.php b/tests/Concurrency/inbox-worker.php
new file mode 100644
index 0000000..1ec0ea0
--- /dev/null
+++ b/tests/Concurrency/inbox-worker.php
@@ -0,0 +1,40 @@
+accept(inboxPage([], 'cursor-start'), 0, null);
+ echo json_encode(['checkpoint' => $inbox->checkpoint()], JSON_THROW_ON_ERROR);
+ exit;
+}
+$db = inboxConnection();
+$inbox = new WarehouseInbox($db, 'synthetic-sgc-a', 7, 99, 'reader');
+if ($mode === 'hold') {
+ $db->beginTransaction();
+ $db->query('SELECT version FROM sgc_exchange_streams FOR UPDATE')->fetchColumn();
+ echo 'locked';
+ flush();
+ usleep(1500000);
+ $db->commit();
+}
+if (! in_array($mode, ['hold', 'run', 'exit-after-commit'], true)) {
+ throw new LogicException('Modo inválido.');
+}
+$id = (int) ($argv[2] ?? 1);
+try {
+ $result = $inbox->accept(inboxPage([inboxEvent($id)], 'cursor-'.$id), 1, 'cursor-start');
+ if ($mode === 'exit-after-commit') {
+ exit(42); // committed, acknowledgement intentionally lost
+ }
+ echo json_encode(['result' => 'accepted', ...$result], JSON_THROW_ON_ERROR);
+} catch (RuntimeException $e) {
+ echo json_encode(['result' => 'conflict'], JSON_THROW_ON_ERROR);
+}
diff --git a/tests/Concurrency/recovery-worker.php b/tests/Concurrency/recovery-worker.php
new file mode 100644
index 0000000..9bca2af
--- /dev/null
+++ b/tests/Concurrency/recovery-worker.php
@@ -0,0 +1,57 @@
+make(Kernel::class)->bootstrap();
+if (! app()->environment('testing') || DB::getDriverName() !== 'pgsql' || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName())) {
+ throw new LogicException('Apenas base descartável.');
+}
+require_once dirname(__DIR__).'/Support/SuggestionFixture.php';
+if (($argv[1] ?? '') === 'prepare') {
+ $f = suggestionFixture();
+ app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'suspend', $f['policy']));
+ auth()->guard()->setUser($f['issuer']);
+ $policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'purpose' => 'internal', 'agreement_key' => $f['agreement']->record_key]));
+ auth()->guard()->setUser($f['receiver']);
+ $policy = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
+ auth()->guard()->setUser($f['issuer']);
+ $rule = app(AutomaticPurchases::class)->save($f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Configuração da prova de recuperação isolada.', 'data' => ['policy_key' => $policy->record_key, 'requester_id' => $f['issuer']->id, 'cost_center_id' => $f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
+ auth()->guard()->setUser($f['receiver']);
+ app(AutomaticPurchases::class)->save($f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Activação independente da regra de ensaio.', 'data' => []]);
+ // Controlled abrupt exit at the start of the post-commit callback. Enqueue remains real.
+ app()->instance(ReplenishmentQueue::class, new class extends ReplenishmentQueue
+ {
+ public function interactive(User $actor, int $id): bool
+ {
+ $work = DB::table('replenishment_work')->where('id', $id)->sole();
+ echo json_encode(['work' => $work->id, 'tenant' => $work->tenant_id, 'stock' => $work->stock_item_id, 'warehouse' => $work->warehouse_id], JSON_THROW_ON_ERROR);
+ exit;
+ }
+ });
+ app(StockWorkflow::class)->record($f['receiver'], ['kind' => 'issue', 'reference' => 'CRASH-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $f['issuer']->id, 'warehouse_id' => $f['warehouse']->id, 'stock_item_id' => $f['stock']->id, 'quantity' => '120.000', 'destination' => 'Oficina interna', 'reason' => 'Saída confirmada antes da interrupção real do processo.']);
+ throw new RuntimeException('A interrupção devia terminar o processo.');
+}
+$data = json_decode($argv[2], true, 32, JSON_THROW_ON_ERROR);
+if (($argv[1] ?? '') === 'hold') {
+ $held = false;
+ DB::listen(function (QueryExecuted $query) use (&$held) {
+ if (! $held && str_contains($query->sql, '"tenants"') && str_contains(strtolower($query->sql), 'for update')) {
+ $held = true;
+ echo "locked\n";
+ flush();
+ usleep(2000000);
+ }
+ });
+}
+$ok = app(ReplenishmentQueue::class)->scheduled($data['work']);
+echo json_encode(['result' => $ok ? 'saved' : 'failed', 'authenticated' => auth()->check(), 'drafts' => DB::table('requisitions')->where('tenant_id', $data['tenant'])->where('status', 'draft')->pluck('id')->all()], JSON_THROW_ON_ERROR);
diff --git a/tests/Concurrency/replenishment-worker.php b/tests/Concurrency/replenishment-worker.php
new file mode 100644
index 0000000..a518dff
--- /dev/null
+++ b/tests/Concurrency/replenishment-worker.php
@@ -0,0 +1,48 @@
+make(Kernel::class)->bootstrap();
+if (! app()->environment('testing') || DB::getDriverName() !== 'pgsql' || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName())) {
+ throw new LogicException('Apenas base descartável.');
+}
+require_once dirname(__DIR__).'/Support/SuggestionFixture.php';
+if (($argv[1] ?? '') === 'prepare') {
+ $f = suggestionFixture();
+ app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'suspend', $f['policy']));
+ auth()->guard()->setUser($f['issuer']);
+ $policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'purpose' => 'internal', 'agreement_key' => $f['agreement']->record_key]));
+ auth()->guard()->setUser($f['receiver']);
+ $policy = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
+ app(StockWorkflow::class)->record($f['receiver'], ['kind' => 'issue', 'reference' => 'EMPTY-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $f['issuer']->id, 'warehouse_id' => $f['warehouse']->id, 'stock_item_id' => $f['stock']->id, 'quantity' => '120.000', 'destination' => 'Oficina interna', 'reason' => 'Consumo efectivo antes da configuração de reposição.']);
+ auth()->guard()->setUser($f['issuer']);
+ $rule = app(AutomaticPurchases::class)->save($f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Configuração fictícia da prova concorrente.', 'data' => ['policy_key' => $policy->record_key, 'requester_id' => $f['issuer']->id, 'cost_center_id' => $f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
+ echo json_encode(['user' => $f['receiver']->id, 'tenant' => $f['tenant']->id, 'stock' => $f['stock']->id, 'warehouse' => $f['warehouse']->id, 'input' => ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Activação concorrente da parametrização testada.', 'data' => []]], JSON_THROW_ON_ERROR);
+ exit;
+}
+$data = json_decode($argv[2], true, 32, JSON_THROW_ON_ERROR);
+$actor = User::query()->withoutGlobalScopes()->findOrFail($data['user']);
+auth()->guard()->setUser($actor);
+if (($argv[1] ?? '') === 'hold') {
+ $held = false;
+ DB::listen(function (QueryExecuted $query) use (&$held) {
+ if (! $held && str_contains($query->sql, '"tenants"') && str_contains(strtolower($query->sql), 'for update')) {
+ $held = true;
+ echo "locked\n";
+ flush();
+ usleep(2000000);
+ }
+ });
+}
+$rule = app(AutomaticPurchases::class)->save($actor, $data['input']);
+$need = app(SupplyPlanning::class)->latest('auto_need')->where('stock_item_id', $data['stock'])->where('warehouse_id', $data['warehouse'])->sole();
+echo json_encode(['result' => 'saved', 'rule' => $rule->id, 'purchases' => $need->data['purchases']], JSON_THROW_ON_ERROR);
diff --git a/tests/Feature/AutomaticPurchasesTest.php b/tests/Feature/AutomaticPurchasesTest.php
index 5ba76c2..7b12fcb 100644
--- a/tests/Feature/AutomaticPurchasesTest.php
+++ b/tests/Feature/AutomaticPurchasesTest.php
@@ -1,24 +1,48 @@
makePartial();
+ $queue->shouldReceive('interactive')->andReturn(false);
+ app()->instance(ReplenishmentQueue::class, $queue);
+ automaticIssue($test, '120.000');
+
+ return DB::table('replenishment_work')->where('tenant_id', $test->f['tenant']->id)->where('stock_item_id', $test->f['stock']->id)->value('id');
+}
+
beforeEach(function () {
$this->f = suggestionFixture();
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
$this->actingAs($this->f['issuer']);
- $policy=app(SupplyPlanning::class)->save($this->f['issuer'],'policy',supplyInput([...supplyPolicy($this->f),'purpose'=>'internal','agreement_key'=>$this->f['agreement']->record_key]));
+ $policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]));
$this->actingAs($this->f['receiver']);
- $this->f['policy']=app(SupplyPlanning::class)->save($this->f['receiver'],'policy',supplyInput([],'approve',$policy));
+ $this->f['policy'] = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
$this->actingAs($this->f['issuer']);
$this->input = ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização autorizada para o ensaio isolado.',
'data' => ['policy_key' => $this->f['policy']->record_key, 'requester_id' => $this->f['issuer']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]];
@@ -33,11 +57,11 @@ function activateAutomaticRule($test)
return app(AutomaticPurchases::class)->save($test->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'reason' => 'Activação independente da parametrização verificada.', 'data' => []]);
}
-function automaticIssue($test, string $quantity)
+function automaticIssue($test, string $quantity, array $extra = [])
{
$test->actingAs($test->f['receiver']);
- return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'TEST-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $test->f['issuer']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'destination' => 'Manutenção interna', 'reason' => 'Consumo físico confirmado no ensaio isolado.']);
+ return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'TEST-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $test->f['issuer']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'destination' => 'Manutenção interna', 'reason' => 'Consumo físico confirmado no ensaio isolado.', ...$extra]);
}
test('automatic purchase reuses approved policy catalogue agreement and tax while preserving human submission and approvals', function () {
@@ -66,6 +90,145 @@ test('missing configuration records a pending need and never invents a requester
expect($need->state)->toBe('pending')->and($need->data['purchases'])->toBe([])->and($need->data['message'])->toContain('Parametrize')->and(Requisition::query()->count())->toBe($before);
});
+test('durable signals survive the missing immediate callback and scheduled recovery never impersonates or duplicates drafts', function () {
+ $id = pilotWork($this);
+ $before = Requisition::query()->count();
+ $row = DB::table('replenishment_work')->find($id);
+ expect($row->generation)->toBeGreaterThan($row->processed_generation);
+ auth()->guard()->logout();
+ expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue()->and(auth()->check())->toBeFalse();
+ $r = Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->latest('id')->firstOrFail();
+ expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1)->and($r->status)->toBe('draft');
+ $audit = DB::table('audit_logs')->where('requisition_id', $r->id)->where('action', 'requisition.automatically_created')->sole();
+ expect(json_decode($audit->after, true)['execution']['mode'])->toBe('scheduled_replenishment')->and($audit->ip_address)->toBeNull();
+ DB::table('replenishment_work')->where('id', $id)->update(['due_at' => now()->subMinute()]);
+ expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue();
+ expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1)->and(app(ReplenishmentRun::class)->tenantId())->toBeNull();
+});
+
+test('failure after draft creation rolls back the draft and checkpoint before a successful retry', function () {
+ $id = pilotWork($this);
+ $before = Requisition::query()->count();
+ $checkpoint = DB::table('replenishment_work')->where('id', $id)->value('processed_generation');
+ auth()->guard()->logout();
+ $injected = false;
+ DB::listen(function ($query) use (&$injected) {
+ if (! $injected && str_contains($query->sql, 'replenishment_work') && str_contains($query->sql, 'processed_generation') && str_starts_with(strtolower($query->sql), 'update')) {
+ $injected = true;
+ throw new RuntimeException('Injected checkpoint interruption');
+ }
+ });
+ expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeFalse();
+ expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before);
+ $row = DB::table('replenishment_work')->find($id);
+ expect($row->processed_generation)->toBe($checkpoint)->and($row->failures)->toBe(1);
+ DB::table('replenishment_work')->where('id', $id)->update(['due_at' => now()->subMinute()]);
+ expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue();
+ expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1);
+});
+
+test('periodic discovery rechecks configured minimum and expired rules without stock movements', function () {
+ activateAutomaticRule($this);
+ automaticIssue($this, '100.000');
+ auth()->guard()->logout();
+ $queue = app(ReplenishmentQueue::class);
+ $queue->discover();
+ expect($queue->drain(100)['failed'])->toBe(0);
+ $need = SupplyRecord::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->where('kind', 'auto_need')->latest('id')->firstOrFail();
+ expect($need->data['purchases'])->toHaveCount(1);
+ $count = DB::table('stock_movements')->where('tenant_id', $this->f['tenant']->id)->count();
+ $this->travel(32)->days();
+ expect($queue->drain(100)['failed'])->toBe(0);
+ $latest = SupplyRecord::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->where('kind', 'auto_need')->latest('id')->firstOrFail();
+ expect($latest->state)->toBe('pending')->and($latest->data['message'])->toContain('expirou')->and(DB::table('stock_movements')->where('tenant_id', $this->f['tenant']->id)->count())->toBe($count);
+});
+
+test('pending replenishment pagination retains old needs and never exposes another tenant', function () {
+ $tenant = $this->f['tenant']->id;
+ for ($i = 0; $i < 105; $i++) {
+ SupplyRecord::query()->create(['tenant_id' => $tenant, 'kind' => 'auto_need', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'pending', 'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'created_by' => $this->f['issuer']->id, 'data' => ['message' => 'Necessidade pendente de teste.'], 'snapshot' => ['item' => ['id' => $this->f['stock']->id], 'warehouse' => ['id' => $this->f['warehouse']->id], 'origins' => []], 'reason' => 'Registo isolado para conferir paginação.', 'operation_key' => (string) Str::uuid(), 'input_hash' => hash('sha256', (string) $i), 'created_at' => now()->subDays(106 - $i)]);
+ }
+ $first = SupplyRecord::query()->where('kind', 'auto_need')->oldest('id')->value('id');
+ $foreign = receiptFixture();
+ SupplyRecord::query()->create(['tenant_id' => $foreign['tenant']->id, 'kind' => 'auto_need', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'pending', 'stock_item_id' => $foreign['stock']->id, 'warehouse_id' => $foreign['warehouse']->id, 'created_by' => $foreign['issuer']->id, 'data' => ['message' => 'Outra empresa'], 'snapshot' => [], 'reason' => 'Origem de outra empresa no ensaio isolado.', 'operation_key' => (string) Str::uuid(), 'input_hash' => str_repeat('a', 64), 'created_at' => now()->subYear()]);
+ $this->actingAs($this->f['issuer']);
+ $this->get('/stock/reposicao?need_state=pending')->assertOk()->assertInertia(fn (Assert $p) => $p->where('needs.total', 105)->has('needs.data', 20)->where('needs.data.0.id', $first)->where('needs.current_page', 1));
+ $this->get('/stock/reposicao?need_state=pending&needs_page=6')->assertOk()->assertInertia(fn (Assert $p) => $p->where('needs.total', 105)->has('needs.data', 5)->where('needs.current_page', 6));
+});
+
+test('a rolled back physical command also rolls back its durable replenishment signal', function () {
+ $this->actingAs($this->f['receiver']);
+ $before = DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->value('generation');
+ expect(fn () => DB::transaction(function () {
+ automaticIssue($this, '1.000');
+ throw new RuntimeException('Interrupted physical transaction');
+ }))->toThrow(RuntimeException::class);
+ expect(DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->value('generation'))->toBe($before)
+ ->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(120000);
+});
+
+test('background execution cannot be borrowed by an authenticated session', function () {
+ $id = pilotWork($this);
+ expect(fn () => app(ReplenishmentQueue::class)->scheduled($id))->toThrow(HttpException::class);
+ expect(app(ReplenishmentRun::class)->tenantId())->toBeNull();
+});
+
+test('the pending work list exposes interrupted articles but recovery still requires an explicit operational task', function () {
+ $id = pilotWork($this);
+ DB::table('replenishment_work')->where('id', $id)->update(['failures' => 1, 'last_error' => 'Recuperação automática pendente.']);
+ $admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
+ supplyGrant($admin, ['admin']);
+ $this->actingAs($admin)->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', false)->where('workItems.total', 1)->where('workItems.data.0.id', $id));
+ $this->post('/stock/reposicao/trabalhos/'.$id.'/avaliar')->assertForbidden();
+ $this->actingAs($this->f['issuer'])->post('/stock/reposicao/trabalhos/'.$id.'/avaliar')->assertRedirect();
+});
+
+test('replenishment workers do not process the SGF composition', function () {
+ $id = pilotWork($this);
+ auth()->guard()->logout();
+ config(['work_context.composition' => 'sgf']);
+ expect(app(ReplenishmentQueue::class)->ready())->toBeFalse();
+ expect(fn () => app(ReplenishmentQueue::class)->scheduled($id))->toThrow(HttpException::class);
+ expect(app(ReplenishmentRun::class)->tenantId())->toBeNull();
+});
+
+test('periodic evaluation detects lot expiry without physical movements and uses the existing draft workflow', function () {
+ supplyGrant($this->f['issuer'], ['inventory_lot_prepare']);
+ supplyGrant($this->f['receiver'], ['inventory_lot_review', 'inventory_lot_release']);
+ $this->actingAs($this->f['issuer']);
+ $setup = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'EXP-PILOT', 'expires_on' => today()->addDays(2)->toDateString()]]], 'prepare'));
+ $this->actingAs($this->f['receiver']);
+ app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $setup));
+ $lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
+ app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
+ activateAutomaticRule($this);
+ $before = Requisition::query()->count();
+ $moves = DB::table('stock_movements')->count();
+ auth()->guard()->logout();
+ $this->travel(3)->days();
+ $queue = app(ReplenishmentQueue::class);
+ $queue->discover();
+ expect($queue->drain(100)['failed'])->toBe(0)->and(DB::table('stock_movements')->count())->toBe($moves)
+ ->and(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1);
+});
+
+test('a rejected automatic purchase does not reopen itself or repeat the rejected purchase', function () {
+ activateAutomaticRule($this);
+ automaticIssue($this, '120.000');
+ app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
+ $need = app(SupplyPlanning::class)->latest('auto_need')->sole();
+ $r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
+ $this->actingAs($this->f['issuer']);
+ app(RequisitionWorkflow::class)->submit($this->f['issuer'], $r);
+ $reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
+ supplyGrant($reviewer, ['department_head']);
+ $this->actingAs($reviewer);
+ app(RequisitionWorkflow::class)->decide($reviewer, $r->fresh(), 'rejected', 'Compra não autorizada para esta necessidade.');
+ app(AutomaticPurchases::class)->evaluate($reviewer, $this->f['stock']->id, $this->f['warehouse']->id);
+ $current = app(SupplyPlanning::class)->latest('auto_need')->sole();
+ expect($r->fresh()->status)->toBe('rejected')->and($current->state)->toBe('pending')->and($current->data['message'])->toContain('rejeitada')->and($current->data['purchases'])->toHaveCount(1);
+});
+
test('a preparer cannot activate their own rule and stale or changed commands do not create duplicate rules', function () {
supplyGrant($this->f['issuer'], ['supply_review']);
$rule = app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input);
@@ -92,6 +255,55 @@ test('expired or changed sources keep the need pending without preventing the ph
test('configuration UI separates preparation review and read and does not generate purchases on GET', function () {
$before = Requisition::query()->count();
$this->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/replenishment')->where('canPrepare', true)->where('canReview', false)->has('requesters', 1));
- $this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', true)->has('requesters',0));
+ $this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', true)->has('requesters', 0));
expect(Requisition::query()->count())->toBe($before);
});
+
+test('warehouse shortage carries the original destination and equipment and is not counted twice after reservation', function () {
+ activateAutomaticRule($this);
+ supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
+ supplyGrant($this->f['receiver'], ['warehouse_request_review']);
+ $this->actingAs($this->f['issuer']);
+ $level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério do ensaio.', 'is_critical' => false, 'created_at' => now()]);
+ $equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id, 'code' => 'EQ-OF', 'description' => 'Equipamento da oficina central', 'area' => 'Oficina', 'status' => 'active']);
+ $request = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'destination' => 'Oficina central, bancada de montagem', 'reason' => 'Componentes para reposição e manutenção interna.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '125.000', 'use' => 'equipment', 'equipment_id' => $equipment->id]]]);
+ app(WarehouseFulfilment::class)->command($this->f['issuer'], $request->id, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'submit', 'reason' => 'Submissão do material para utilização interna.', 'partial_allowed' => true]);
+ $this->actingAs($this->f['receiver']);
+ app(WarehouseFulfilment::class)->command($this->f['receiver'], $request->id, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Autorização parcial com saldo em falta.', 'partial_allowed' => true]);
+ app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
+ $need = app(SupplyPlanning::class)->latest('auto_need')->sole();
+ $r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
+ expect($r->items()->sole()->quantity)->toBe('108.000')->and($need->snapshot['origins'][0]['destination'])->toBe('Oficina central, bancada de montagem')
+ ->and($need->snapshot['origins'][0]['reserved_milli'])->toBe(120000)->and($need->snapshot['unreserved_milli'])->toBe(5000);
+ $origin = app(AutomaticPurchases::class)->origin($r);
+ expect($origin['origins'][0]['warehouse_request_id'])->toBe($request->id);
+ expect($origin['origins'][0]['equipment']['code'])->toBe('EQ-OF');
+ $this->actingAs($this->f['issuer'])->get('/requisicoes/'.$r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('automaticSource.origins.0.destination', 'Oficina central, bancada de montagem'));
+});
+
+test('revoked requester authority or expired work context cannot be borrowed for automatic purchases', function () {
+ activateAutomaticRule($this);
+ DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
+ $before = Requisition::query()->count();
+ automaticIssue($this, '120.000');
+ app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
+ expect(app(SupplyPlanning::class)->latest('auto_need')->sole()->data['message'])->toContain('competência')->and(Requisition::query()->count())->toBe($before);
+});
+
+test('automatic spare part purchases reuse the registered compatible equipment and reject a missing technical destination', function () {
+ $level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'PART', 'name' => 'Normal', 'criteria' => 'Critério do ensaio.', 'is_critical' => false, 'created_at' => now()]);
+ $equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id, 'code' => 'EQ-P', 'description' => 'Máquina de teste', 'area' => 'Oficina', 'status' => 'active']);
+ $part = SparePart::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 20000]);
+ EquipmentSparePart::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'equipment_id' => $equipment->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
+ $this->actingAs($this->f['receiver']);
+ app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 1, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']), 'category' => 'Peças', 'purchase_type' => 'spare_part', 'published' => true, 'reason' => 'Peça publicada para aquisição com destino técnico.']);
+ $this->actingAs($this->f['issuer']);
+ expect(fn () => app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input))->toThrow(ValidationException::class);
+ $this->input['data']['equipment_id'] = $equipment->id;
+ activateAutomaticRule($this);
+ automaticIssue($this, '120.000', ['equipment_id' => $equipment->id]);
+ app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
+ $need = app(SupplyPlanning::class)->latest('auto_need')->sole();
+ $r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
+ expect($r->items()->sole()->equipment_id)->toBe($equipment->id)->and($r->items()->sole()->spare_part_id)->toBe($part->id)->and($r->status)->toBe('draft');
+});
diff --git a/tests/Feature/CommonReceiptStockTest.php b/tests/Feature/CommonReceiptStockTest.php
new file mode 100644
index 0000000..60134ef
--- /dev/null
+++ b/tests/Feature/CommonReceiptStockTest.php
@@ -0,0 +1,71 @@
+assertMatchesRegularExpression('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name);$tag=substr($i->name,-12);
+ $this->assertSame('stock_receipt_qa_'.$tag,$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster);
+ if(!DB::getSchemaBuilder()->hasTable('users')){Artisan::call('migrate',['--force'=>true]);}
+ $this->assertSame(0,DB::table('users')->count(),'Use the fresh QA database, never a retained fixture');
+ $root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);
+ $f=\receiptFixture('10.000');$this->actingAs($f['receiver']);$url='/ordens/'.$f['order']->id.'/recepcoes';
+ $body=\receiptPayload($f,'6.000');$body['items'][0]['accepted_quantity']='4.000';$body['items'][0]['quality_status']='reservations';$body['items'][0]['notes']='Synthetic acceptance: four accepted, two damaged.';
+ $receipt=$this->postJson($url,$body)->assertCreated()->json('id');
+ $this->postJson($url,$body)->assertOk()->assertJsonPath('id',$receipt)->assertJsonPath('created',false);
+ $line=GoodsReceiptItem::query()->where('goods_receipt_id',$receipt)->firstOrFail();$movement=StockMovement::query()->where('goods_receipt_item_id',$line->id)->firstOrFail();
+ $this->assertSame(4000,(int)$movement->quantity_milli);$this->assertSame(2000,(int)$line->rejected_milli);
+ $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/D',$movement->snapshot['inventory_source_hash']);
+ $coreReplay=DB::transaction(function () use ($f,$line,$movement){
+ app(ConflictGuard::class)->lockCompany($f['receiver']);$w=app(ReceiptStockMovement::class)->for($f['receiver']);
+ return $w->write($line->id,$movement->snapshot['inventory_source_hash']);
+ });$this->assertTrue($coreReplay['replayed']);$this->assertSame($movement->id,$coreReplay['movement_id']);
+ try{DB::transaction(function()use($f,$line){app(ConflictGuard::class)->lockCompany($f['receiver']);app(ReceiptStockMovement::class)->for($f['receiver'])->write($line->id,str_repeat('0',64));});$this->fail('Old origin hash must be refused');}
+ catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());}
+ $bad=$body;$bad['items'][0]['accepted_quantity']='3.000';$this->postJson($url,$bad)->assertUnprocessable();
+ $rejected=\receiptPayload($f,'2.000');$rejected['warehouse_id']=null;$rejected['items'][0]['stock_item_id']=null;$rejected['items'][0]['accepted_quantity']='0';$rejected['items'][0]['quality_status']='nonconforming';$rejected['items'][0]['notes']='Synthetic rejection.';
+ $this->postJson($url,$rejected)->assertCreated()->assertJsonPath('status','rejected');
+ $this->assertSame(1,StockMovement::query()->count());
+ $this->actingAs($f['issuer'])->postJson($url,\receiptPayload($f,'1.000'))->assertForbidden();$this->actingAs($f['receiver']);
+ // Fault after the shared SQL insert must undo receipt, movement and native audit.
+ $failure=\receiptFixture();$this->actingAs($failure['receiver']);$payload=\receiptPayload($failure,'1.000');$fault=true;
+ $before=array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs']);
+ DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*stock_movements/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic failure after inventory insert');}});
+ try{app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload);$this->fail('Injected failure missing');}
+ catch(\RuntimeException $e){$this->assertSame('Synthetic failure after inventory insert',$e->getMessage());}
+ $this->assertSame($before,array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs']));
+ $recovered=app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload);
+ $this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$recovered->items->pluck('id'))->count());
+ // Two real PHP processes contend on the company lock, then recover the same receipt.
+ $concurrent=\receiptFixture();$this->actingAs($concurrent['receiver']);$payload=\receiptPayload($concurrent,'1.000');$process=null;
+ DB::beginTransaction();
+ try{
+ $winner=app(ReceiptWorkflow::class)->confirm($concurrent['receiver'],$concurrent['order'],$payload);
+ $input=$root.'/worker.json';$application='stock_receipt_worker_'.$tag;
+ file_put_contents($input,json_encode(['actor'=>$concurrent['receiver']->id,'order'=>$concurrent['order']->id,'payload'=>$payload,'application'=>$application],JSON_THROW_ON_ERROR));
+ $pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/common-receipt-worker.php'),$input],[0=>['pipe','r'],1=>['file',$root.'/worker.stdout','w'],2=>['file',$root.'/worker.stderr','w']],$pipes,base_path());
+ $this->assertIsResource($process);fclose($pipes[0]);$deadline=microtime(true)+10;$wait=null;$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS pid')->pid;
+ do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT wait_event,to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);
+ $this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit();
+ $deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);
+ $this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);
+ $second=json_decode(file_get_contents($input.'.result'),true,512,JSON_THROW_ON_ERROR);$this->assertSame(200,$second['status']);$this->assertSame($winner->id,$second['receipt_id']);
+ $this->assertSame(1,GoodsReceipt::query()->where('purchase_order_id',$concurrent['order']->id)->count());
+ $this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$winner->items->pluck('id'))->count());
+ }finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
+ file_put_contents($root.'/receipt-results.json',json_encode(['identity'=>$i,'physical_accepted_milli'=>4000,'rejected_milli'=>2000,'movement_count_main'=>1,
+ 'source_version_stale_refused'=>true,'core_replay_same_movement'=>true,'fault_atomic_and_recovered'=>true,'concurrency'=>['actual_lock_wait'=>true,'wait_event'=>$wait->wait_event,'same_receipt'=>true,'one_movement'=>true],
+ 'link'=>['tenant'=>(int)$f['tenant']->id,'warehouse'=>(int)$f['warehouse']->id,'item'=>(int)$f['stock']->id,'movement'=>(int)$movement->id,'reference'=>$movement->snapshot['reference']],
+ 'writer_manifest_sha256'=>hash_file('sha256',base_path('packages/inventory-write/releases/0.1.0/manifest.json'))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/tests/Feature/CommonStockReadTest.php b/tests/Feature/CommonStockReadTest.php
new file mode 100644
index 0000000..aade9db
--- /dev/null
+++ b/tests/Feature/CommonStockReadTest.php
@@ -0,0 +1,85 @@
+assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',$run);
+ $identity=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
+ $this->assertMatchesRegularExpression('/^stock_read_compras_([a-f0-9]{12})$/D',$identity->name);
+ $tag=substr($identity->name,-12);
+ $this->assertSame('stock_read_qa_'.$tag,$identity->role);$this->assertSame(55439,(int)$identity->port);$this->assertSame('sgf_modular_regression_'.$run,$identity->cluster);
+ // Fresh, dedicated QA database only. Never migrate/reset any existing host.
+ $this->assertFalse(DB::getSchemaBuilder()->hasTable('users'));
+ DB::transaction(function () {
+ DB::statement('CREATE TABLE tenants(id bigint PRIMARY KEY,name text,currency text)');
+ DB::statement("INSERT INTO tenants VALUES(77,'Synthetic native company','MZN'),(88,'Foreign company','MZN')");
+ DB::statement('CREATE TABLE users(id bigserial PRIMARY KEY,tenant_id bigint,department_id bigint,name text,email text,password text,purchasing_role text,email_verified_at timestamp,remember_token text,created_at timestamp,updated_at timestamp)');
+ DB::statement('CREATE TABLE organization_access(tenant_id bigint,active boolean)');
+ DB::statement('CREATE TABLE organization_profiles(id bigint PRIMARY KEY,tenant_id bigint,name text,permissions jsonb,work_contexts jsonb)');
+ DB::statement('CREATE TABLE organization_memberships(id bigint PRIMARY KEY,tenant_id bigint,user_id bigint,profile_id bigint,expires_at timestamp,maintenance boolean)');
+ });
+ $actor=User::forceCreate(['tenant_id'=>77,'purchasing_role'=>'warehouse','name'=>'Synthetic warehouse','email'=>'stock@example.test','password'=>bin2hex(random_bytes(16))]);
+ $denied=User::forceCreate(['tenant_id'=>77,'purchasing_role'=>'requester','name'=>'Synthetic requester','email'=>'request@example.test','password'=>bin2hex(random_bytes(16))]);
+ $schema='sgf_stock_read_'.bin2hex(random_bytes(6));
+ DB::transaction(function () use ($schema) {
+ DB::statement("CREATE SCHEMA $schema");
+ DB::statement("CREATE TABLE $schema.stock_items(id bigint PRIMARY KEY,tenant_id bigint,code text,name text,unit text)");
+ DB::statement("CREATE TABLE $schema.warehouses(id bigint PRIMARY KEY,tenant_id bigint,code text,name text)");
+ DB::statement("CREATE TABLE $schema.stock_movements(id bigserial PRIMARY KEY,tenant_id bigint,stock_item_id bigint,warehouse_id bigint,quantity_milli bigint,movement_type text,snapshot jsonb,moved_at timestamp)");
+ DB::statement("INSERT INTO $schema.warehouses VALUES(11,77,'WH-A','Synthetic A'),(12,77,'WH-B','Synthetic B'),(13,77,'HIDDEN','Restricted warehouse'),(21,88,'FOREIGN','Foreign warehouse')");
+ DB::statement("INSERT INTO $schema.stock_items SELECT i,77,'QA-'||lpad(i::text,6,'0'),'Synthetic item '||i,'un' FROM generate_series(1,100) i");
+ DB::statement("INSERT INTO $schema.stock_items VALUES(101,77,'ZERO','Without movements','un'),(9999999,88,'SECRET','Foreign item','un')");
+ DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
+ SELECT 77,i,11,10000,'receipt',jsonb_build_object('reference','QA-R-'||i,'private_document','DO_NOT_RETURN'),'2026-11-01'::timestamp FROM generate_series(1,100) i");
+ DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
+ SELECT 77,i,11,-2000,'issue',jsonb_build_object('reference','QA-I-'||i),'2026-11-02'::timestamp FROM generate_series(1,100) i");
+ DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at) VALUES(77,1,13,99000,'hidden','{}',now()),(88,9999999,21,88000,'foreign','{}',now())");
+ DB::statement("CREATE FUNCTION $schema.immutable() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''Inventory ledger is append-only''; END'");
+ DB::statement("CREATE TRIGGER immutable BEFORE UPDATE OR DELETE ON $schema.stock_movements FOR EACH ROW EXECUTE FUNCTION $schema.immutable()");
+ });
+ $binding=['schema'=>$schema,'source_tenant_id'=>77,'warehouse_ids'=>[11,12],'append_only'=>true];
+ $key='77:'.$actor->id;config(['stock_read.synthetic_sources.'.$key=>$binding]);
+ $this->getJson('/api/stock-read')->assertUnauthorized();
+ $this->actingAs($denied)->getJson('/api/stock-read')->assertForbidden();
+ $this->actingAs($actor);
+ $queries=[];foreach(['items','warehouses','balances','movements'] as $view)foreach([25,100] as $size)$queries[]='view='.$view.'&per_page='.$size;
+ $queries=[...$queries,'view=balances&per_page=25&page=2','view=items&per_page=25&page=5','view=balances&warehouse_id=12','view=items&search=QA-000001'];
+ $cases=[];
+ foreach($queries as $query){
+ $r=$this->getJson('/api/stock-read?'.$query)->assertOk();
+ $this->assertStringNotContainsString('DO_NOT_RETURN',$r->getContent());$this->assertStringNotContainsString('Foreign',$r->getContent());$this->assertStringNotContainsString('Restricted warehouse',$r->getContent());
+ $cases[$query]=['sha256'=>hash('sha256',json_encode([$r->json('rows'),$r->json('pagination')],JSON_THROW_ON_ERROR)),'total'=>$r->json('pagination.total')];
+ }
+ $snapshot=$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000')->json('snapshot');
+ $this->getJson('/api/stock-read?warehouse_id=13')->assertForbidden();$this->getJson('/api/stock-read?warehouse_id=21')->assertForbidden();
+ $this->getJson('/api/stock-read?tenant_id=88')->assertUnprocessable();$this->getJson('/api/stock-read?per_page=101')->assertUnprocessable();
+ $this->getJson('/api/stock-read?snapshot=invalid')->assertConflict();
+ DB::table($schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>-1000,'movement_type'=>'issue','snapshot'=>'{}','moved_at'=>now()]);
+ $this->getJson('/api/stock-read?snapshot='.urlencode($snapshot))->assertConflict();
+ $this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','7000');
+ // Exercise configured native permissions too, without inventing a new right.
+ DB::table('organization_access')->insert(['tenant_id'=>77,'active'=>true]);
+ DB::table('organization_profiles')->insert(['id'=>1,'tenant_id'=>77,'name'=>'Synthetic warehouse profile','permissions'=>'["warehouse"]','work_contexts'=>'[]']);
+ DB::table('organization_memberships')->insert(['id'=>1,'tenant_id'=>77,'user_id'=>$actor->id,'profile_id'=>1,'maintenance'=>false]);
+ $this->getJson('/api/stock-read')->assertOk();
+ DB::table('organization_profiles')->where('id',1)->update(['permissions'=>'["requester"]']);
+ $this->getJson('/api/stock-read')->assertForbidden();
+ DB::table('organization_profiles')->where('id',1)->update(['permissions'=>'["warehouse"]']);
+ $this->getJson('/api/stock-read')->assertOk();
+ config(['stock_read.synthetic_sources.'.$key=>null]);$this->getJson('/api/stock-read')->assertStatus(503)->assertJsonPath('status','unavailable');
+ config(['stock_read.synthetic_sources.'.$key=>array_replace($binding,['source_tenant_id'=>88])]);$this->getJson('/api/stock-read')->assertStatus(503);
+ config(['stock_read.synthetic_sources.'.$key=>$binding]);$this->getJson('/api/stock-read')->assertOk();
+ $output=getenv('STOCK_READ_COMPOSITION_OUTPUT');$root=realpath((string)getenv('STOCK_READ_COMPOSITION_ROOT'));
+ if(!$output||!$root||realpath(dirname($output))!==$root)throw new \RuntimeException('Unexpected private proof directory');
+ file_put_contents($output,json_encode(['identity'=>$identity,'cases'=>$cases,'native_snapshot'=>$snapshot,'native_roles_and_profiles_revalidated'=>true,
+ 'warehouse_isolation'=>true,'stale_snapshot_refused'=>true,'unknown_source_refused'=>true,'package_manifest_sha256'=>config('stock_read.package_manifest_sha256')],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/tests/Feature/DocumentaryQualificationTest.php b/tests/Feature/DocumentaryQualificationTest.php
index 975275a..1c560ae 100644
--- a/tests/Feature/DocumentaryQualificationTest.php
+++ b/tests/Feature/DocumentaryQualificationTest.php
@@ -16,6 +16,7 @@ use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/QualificationFixture.php';
+require_once __DIR__.'/../Support/SupplyFixture.php';
beforeEach(function () {
$this->f = qualificationFixture();
@@ -51,6 +52,46 @@ test('documentary qualification freezes process criteria without financial decis
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('kind', 'Qualificação documental')));
});
+test('formal task documentary resumes only my incomplete analysis and reuses the existing answers', function () {
+ $set = defineDocumentaryQualification($this);
+ $first = recordDocumentaryAssessment($this, $set);
+ $path = '/requisicoes/tarefas?module=purchases&search=documental';
+ $this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.href', $this->url.'?version='.$set->id.'"ation_id='.$this->f['quote']->id.'#qualification-assess'));
+ $this->get($this->url.'?version='.$set->id.'"ation_id='.$this->f['quote']->id)->assertInertia(fn (Assert $page) => $page
+ ->where('focusQuotationId', $this->f['quote']->id)->where('canAssess', true)->where('records.0.id', $first->id)
+ ->where('records.0.answers.0.evidence', $this->f['assessment']['answers'][0]['evidence']));
+ $this->actingAs($this->f['other_analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $answers = $first->answers;
+ $answers[1] = ['criterion_id' => 2, 'result' => 'not_met', 'evidence' => 'Documento sintético', 'reason' => 'Resultado negativo fundamentado no ensaio.'];
+ recordDocumentaryAssessment($this, $set, ['previous_id' => $first->id, 'answers' => $answers]);
+ $this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect($first->fresh()->answers[1]['result'])->toBe('pending')->and(QualificationAssessment::query()->count())->toBe(2)->and(BudgetEntry::query()->count())->toBe(0);
+});
+
+test('formal task documentary does not continue an old basis or create new analyst assignments', function () {
+ $set = defineDocumentaryQualification($this);
+ $first = recordDocumentaryAssessment($this, $set);
+ $before = [QualificationAssessment::query()->count(), AuditLog::query()->count()];
+ $this->get('/requisicoes/tarefas?module=purchases&search=documental')->assertOk();
+ expect([QualificationAssessment::query()->count(), AuditLog::query()->count()])->toBe($before);
+ $this->f['quote']->update(['reference' => 'REFERÊNCIA-ALTERADA']);
+ $this->get('/requisicoes/tarefas?module=purchases&search=documental')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->url.'?quotation_id='.$this->f['quote']->id)->assertInertia(fn (Assert $page) => $page->where('isCurrent', false)->where('canAssess', false));
+ $this->post('/qualificacoes/'.$set->id.'/analises', [...$this->f['assessment'], 'previous_id' => $first->id])->assertSessionHasErrors('qualification');
+ $this->getJson($this->url.'?quotation_id=999999')->assertUnprocessable();
+});
+
+test('formal task documentary does not let an administrative reading grant authorise another department analysis', function () {
+ $set = defineDocumentaryQualification($this);
+ $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento sintético']);
+ $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
+ supplyGrant($head, ['admin', 'department_head']);
+ $this->actingAs($head)->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canAssess', false));
+ $this->post('/qualificacoes/'.$set->id.'/analises', $this->f['assessment'])->assertForbidden();
+ expect(QualificationAssessment::query()->count())->toBe(0);
+});
+
test('documentary criteria versions and analyst corrections never overwrite earlier records', function () {
$set = defineDocumentaryQualification($this);
$first = recordDocumentaryAssessment($this, $set);
diff --git a/tests/Feature/ExternalPaymentTest.php b/tests/Feature/ExternalPaymentTest.php
index e0e4dac..f1d6544 100644
--- a/tests/Feature/ExternalPaymentTest.php
+++ b/tests/Feature/ExternalPaymentTest.php
@@ -206,10 +206,12 @@ test('financial role is reloaded after the company lock rather than trusting a s
expect(ExternalPayment::query()->count())->toBe(0);
});
-test('payables trial stays unavailable outside its isolated database even with a source configured', function () {
- config(['common-payables.source_instance' => 'synthetic-source']);
+test('payables trial never inherits financial authority and only activates automatically in isolated PostgreSQL', function () {
+ config(['common-payables.enabled' => false, 'common-payables.source_instance' => 'synthetic-source']);
- expect(SupplierPayables::enabled())->toBeFalse();
+ // paymentFixture rejects PostgreSQL databases outside the disposable test namespace.
+ // The same source alone keeps the module disabled on the normal SQLite suite.
+ expect(SupplierPayables::enabled())->toBe(DB::getDriverName() === 'pgsql');
$this->get('/obrigacoes-fornecedores')->assertForbidden();
$this->getJson('/api/supplier-payables')->assertForbidden();
$this->postJson('/api/supplier-payables/prepare', ['id' => $this->f['invoice']->id, 'reason' => 'Ensaio isolado'])->assertForbidden();
diff --git a/tests/Feature/FinanceIntegrationTest.php b/tests/Feature/FinanceIntegrationTest.php
new file mode 100644
index 0000000..62bd010
--- /dev/null
+++ b/tests/Feature/FinanceIntegrationTest.php
@@ -0,0 +1,84 @@
+f = invoiceFixture();
+ $this->actingAs($this->f['author']);
+});
+
+test('compact invoice registration reuses order facts and preserves independent totals and validation', function () {
+ $data = [...$this->f['data'], 'number' => 'FT-COMPACT-2', 'subtotal' => '40.00', 'total' => '40.00',
+ 'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '0.400']]];
+ $this->postJson('/ordens/'.$this->f['order']->id.'/facturas', $data)->assertRedirect();
+ $invoice = Invoice::query()->where('number', 'FT-COMPACT-2')->sole();
+ expect($invoice->status)->toBe('pending')->and($invoice->items()->sole()->snapshot['unit'])->toBe('cx')
+ ->and((int) $invoice->items()->sole()->snapshot['unit_price_minor'])->toBe(10000)->and(BudgetConsumption::query()->count())->toBe(0);
+ $this->actingAs($this->f['validator']);
+ app(InvoiceWorkflow::class)->validate($this->f['validator'], $this->f['invoice']);
+ app(InvoiceWorkflow::class)->validate($this->f['validator'], $invoice);
+ expect((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(10000);
+});
+
+test('compact registration still refuses forged supplied facts quantity and documentary totals', function () {
+ $base = [...$this->f['data'], 'number' => 'FORGED', 'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '0.600']]];
+ foreach ([['unit' => 'kg'], ['unit_price' => '99.00'], ['tax_rate' => '16'], ['quantity' => '2.000']] as $extra) {
+ $this->postJson('/ordens/'.$this->f['order']->id.'/facturas', [...$base, 'items' => [[...$base['items'][0], ...$extra]]])->assertUnprocessable();
+ }
+ $this->postJson('/ordens/'.$this->f['order']->id.'/facturas', [...$base, 'total' => '59.99'])->assertUnprocessable();
+ expect(Invoice::query()->count())->toBe(1)->and(BudgetConsumption::query()->count())->toBe(0);
+});
+
+test('finance task belongs to an independent finance user and reaches the exact invoice', function () {
+ $invoice = $this->f['invoice'];
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->f['validator'])->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.href', '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id));
+ $this->get('/facturas?invoice_id='.$invoice->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->where('focusInvoiceId', $invoice->id)->where('invoices.total', 1)->where('invoices.data.0.canValidate', true));
+ $this->post('/facturas/'.$invoice->id.'/validar')->assertRedirect();
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->f['author'])->get('/facturas?purchase_order_id='.$this->f['order']->id)->assertInertia(fn (Assert $page) => $page->where('focusOrderId', $this->f['order']->id));
+});
+
+test('finance tasks respect declared conflicts current permissions and maintenance without changing facts', function () {
+ $actor = $this->f['validator'];
+ $this->actingAs($actor);
+ $before = [DB::table('invoices')->get()->toArray(), DB::table('budget_consumptions')->count(), DB::table('audit_logs')->count()];
+ $this->get('/requisicoes/tarefas?module=finance')->assertOk();
+ expect([DB::table('invoices')->get()->toArray(), DB::table('budget_consumptions')->count(), DB::table('audit_logs')->count()])->toEqual($before);
+ $guard = Mockery::mock(ConflictGuard::class)->makePartial();
+ $guard->shouldReceive('processBlocked')->andReturn(true);
+ app()->instance(ConflictGuard::class, $guard);
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/facturas')->assertInertia(fn (Assert $page) => $page->where('invoices.data.0.canValidate', false));
+});
+
+test('non finance users cannot inherit invoice validation tasks or cross company invoice focus', function () {
+ $admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
+ $this->actingAs($admin)->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/facturas?invoice_id='.$this->f['invoice']->id)->assertInertia(fn (Assert $page) => $page->where('invoices.data.0.canValidate', false));
+ $foreign = receiptFixture();
+ $foreignFinance = User::factory()->create(['tenant_id' => $foreign['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
+ $this->actingAs($foreignFinance)->getJson('/facturas?invoice_id='.$this->f['invoice']->id)->assertUnprocessable();
+});
+
+test('maintenance finance can consult but never inherits invoice execution', function () {
+ $actor = $this->f['validator'];
+ supplyGrant($actor, ['finance']);
+ DB::table('organization_memberships')->where('user_id', $actor->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
+ $this->actingAs($actor);
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/facturas')->assertInertia(fn (Assert $page) => $page->where('canManage', false)->where('invoices.data.0.canValidate', false));
+ $this->post('/facturas/'.$this->f['invoice']->id.'/validar')->assertForbidden();
+ expect($this->f['invoice']->fresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0);
+});
diff --git a/tests/Feature/FormalAdmissionTest.php b/tests/Feature/FormalAdmissionTest.php
index e4ca7cb..3414a96 100644
--- a/tests/Feature/FormalAdmissionTest.php
+++ b/tests/Feature/FormalAdmissionTest.php
@@ -12,11 +12,13 @@ use App\Models\Tenant;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\FormalAdmission;
+use App\Services\ProposalOpeningWorkflow;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/AdmissionFixture.php';
+require_once __DIR__.'/../Support/SupplyFixture.php';
beforeEach(function () {
$this->f = admissionFixture();
@@ -166,3 +168,71 @@ test('active process conflict prevents conference without creating approval', fu
$this->actingAs($this->f['analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência bloqueada por conflito'])->assertSessionHasErrors('conflict');
expect(AdmissionReview::query()->count())->toBe(0);
});
+
+test('formal task admission follows the adopted quorum and removes only the completed personal intervention', function () {
+ $decision = admissionAttempt($this);
+ $path = '/requisicoes/tarefas?module=purchases&search=admissão';
+ $this->actingAs($this->f['analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.href', $this->url.'?version='.$this->f['adoption']->id.'&decision_id='.$decision->id.'#admission-'.$decision->id)
+ ->missing('tasks.data.0.snapshot')->missing('tasks.data.0.reason')->missing('tasks.data.0.basis_hash'));
+ $this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('focusDecisionId', $decision->id)->where('history.data.0.canReview', true));
+ // A different company policy does not replace the process's adopted quorum/roles.
+ $this->actingAs($this->f['admin']);
+ app(FormalAdmission::class)->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'roles' => ['department_head'], 'required_count' => 1]);
+ admissionConfer($this, $decision);
+ $this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect(app(FormalAdmission::class)->status($decision, $this->f['adoption']))->toBe('pending');
+ $this->actingAs($this->f['other_analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
+ admissionConfer($this, $decision, $this->f['other_analyst']);
+ $this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect(AdmissionReview::query()->count())->toBe(2)->and(BudgetEntry::query()->count())->toBe(0);
+});
+
+test('formal task admission retains an older current decision and opens it beyond the first history page', function () {
+ $supplier = $this->f['quote']->supplier->replicate();
+ $supplier->fill(['nuit' => '987654321', 'name' => 'Segundo fornecedor fictício'])->save();
+ $quote = $this->f['quote']->replicate();
+ $quote->fill(['supplier_id' => $supplier->id, 'reference' => 'SEGUNDA-FICTICIA'])->save();
+ $this->actingAs($this->f['issuer']);
+ $opening = app(ProposalOpeningWorkflow::class);
+ $set = app(DocumentaryQualificationWorkflow::class)->define($this->f['issuer'], $this->f['r'], [...$this->f['definition'], 'previous_id' => $this->f['set']->id, 'basis_hash' => $opening->basis($opening->proposals($this->f['r']))]);
+ $this->actingAs($this->f['analyst']);
+ foreach ([$this->f['quote'], $quote] as $candidate) {
+ app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $set, [...$this->f['assessment'], 'quotation_id' => $candidate->id]);
+ }
+ $service = app(FormalAdmission::class);
+ $first = admissionAttempt($this, ['basis_hash' => $service->basis($this->f['r'], $this->f['quote']->id)['hash']]);
+ $previous = 0;
+ for ($index = 0; $index < 15; $index++) {
+ $next = admissionAttempt($this, ['quotation_id' => $quote->id, 'previous_id' => $previous, 'basis_hash' => $service->basis($this->f['r'], $quote->id)['hash']]);
+ $previous = $next->id;
+ }
+ $this->actingAs($this->f['other_analyst'])->get($this->url)->assertInertia(fn (Assert $page) => $page->where('history.total', 16)->where('history.data.0.id', $next->id));
+ $this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)->where('tasks.data.0.id', 'admission:'.$first->id.':review'));
+ $this->get($this->url.'?version='.$this->f['adoption']->id.'&decision_id='.$first->id)->assertInertia(fn (Assert $page) => $page->where('history.total', 1)->where('history.data.0.id', $first->id)->where('history.data.0.canReview', true));
+});
+
+test('formal task admission respects changed bases conflicts and consultation never records a decision', function () {
+ $decision = admissionAttempt($this);
+ $this->actingAs($this->f['analyst']);
+ $before = [DB::table('admission_reviews')->count(), DB::table('audit_logs')->count(), DB::table('admission_decisions')->get()->toArray()];
+ $this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertOk();
+ $this->get($this->url.'?decision_id='.$decision->id)->assertOk();
+ expect([DB::table('admission_reviews')->count(), DB::table('audit_logs')->count(), DB::table('admission_decisions')->get()->toArray()])->toEqual($before);
+ ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['analyst']->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito sintético impeditivo.']);
+ $this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false));
+ $this->f['quote']->update(['reference' => 'BASE-ALTERADA']);
+ expect(app(FormalAdmission::class)->currentReviewBasis($this->f['r'], $this->f['adoption'], $decision))->toBeFalse();
+});
+
+test('formal task admission cannot grant review to maintenance or focus another adoption', function () {
+ $decision = admissionAttempt($this);
+ supplyGrant($this->f['analyst'], ['finance']);
+ DB::table('organization_memberships')->where('user_id', $this->f['analyst']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
+ $this->actingAs($this->f['analyst'])->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false)->where('canPropose', false)->where('canAdopt', false));
+ $this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Não executar em manutenção.'])->assertForbidden();
+ expect(AdmissionReview::query()->count())->toBe(0);
+ $this->getJson($this->url.'?decision_id=999999')->assertUnprocessable();
+});
diff --git a/tests/Feature/FormalRecommendationTest.php b/tests/Feature/FormalRecommendationTest.php
index b377743..9202f93 100644
--- a/tests/Feature/FormalRecommendationTest.php
+++ b/tests/Feature/FormalRecommendationTest.php
@@ -23,6 +23,7 @@ use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/RecommendationFixture.php';
+require_once __DIR__.'/../Support/SupplyFixture.php';
beforeEach(function () {
$this->f = recommendationFixture();
@@ -80,6 +81,68 @@ test('recommendation retries count one person once and returned attempt cannot a
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
});
+test('formal task recommendation keeps the adopted quorum and forwards only unfinished personal reviews', function () {
+ $attempt = recommendationAttempt($this);
+ $path = '/requisicoes/tarefas?module=purchases&search=recomendação';
+ $this->actingAs($this->f['reviewer'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.href', $this->url.'?version='.$this->f['adoption']->id.'&attempt_id='.$attempt->id.'#recommendation-'.$attempt->id)
+ ->missing('tasks.data.0.snapshot')->missing('tasks.data.0.basis_hash'));
+ $this->get($this->url.'?attempt_id='.$attempt->id)->assertInertia(fn (Assert $page) => $page->where('focusAttemptId', $attempt->id)->where('history.data.0.canReview', true));
+ $this->actingAs($this->f['admin']);
+ app(FormalRecommendation::class)->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'roles' => ['finance'], 'required_count' => 5]);
+ recommendationConfer($this, $attempt);
+ $this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->f['head'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
+ recommendationConfer($this, $attempt, $this->f['head']);
+ $this->actingAs($this->f['other_reviewer'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('confirmed')
+ ->and(RecommendationReview::query()->count())->toBe(2)->and(PurchaseAward::query()->count())->toBe(0)->and(BudgetEntry::query()->count())->toBe(0);
+});
+
+test('formal task recommendation never turns a historical focused attempt into the current pending one', function () {
+ $first = recommendationAttempt($this);
+ $previous = $first;
+ for ($index = 0; $index < 10; $index++) {
+ $previous = recommendationAttempt($this, ['previous_id' => $previous->id]);
+ }
+ $this->actingAs($this->f['reviewer'])->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.id', 'recommendation:'.$previous->id.':review'));
+ $this->get($this->url.'?attempt_id='.$first->id)->assertInertia(fn (Assert $page) => $page->where('history.total', 1)->where('history.data.0.id', $first->id)
+ ->where('history.data.0.current', false)->where('history.data.0.canReview', false));
+ $this->get($this->url.'?attempt_id='.$previous->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.current', true)->where('history.data.0.canReview', true));
+ $this->getJson($this->url.'?attempt_id=999999')->assertUnprocessable();
+});
+
+test('formal task recommendation respects matrix changes without mutating the attempt or recording decisions', function () {
+ $attempt = recommendationAttempt($this);
+ $this->actingAs($this->f['reviewer']);
+ $before = [DB::table('recommendation_attempts')->get()->toArray(), RecommendationReview::query()->count(), AuditLog::query()->count()];
+ $this->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertOk();
+ $this->get($this->url.'?attempt_id='.$attempt->id)->assertOk();
+ expect([DB::table('recommendation_attempts')->get()->toArray(), RecommendationReview::query()->count(), AuditLog::query()->count()])->toEqual($before);
+ ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->update(['quality' => '1']);
+ $this->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->url.'?attempt_id='.$attempt->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false));
+ $this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Base alterada exige nova tentativa.'])->assertSessionHasErrors('recommendation');
+ expect(RecommendationReview::query()->count())->toBe(0);
+});
+
+test('formal task recommendation respects department company maintenance and current reviewer authority', function () {
+ $attempt = recommendationAttempt($this);
+ $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra unidade sintética']);
+ $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
+ $this->actingAs($head)->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->url.'?attempt_id='.$attempt->id)->assertNotFound();
+ $tenant = Tenant::query()->create(['name' => 'Outra empresa sintética']);
+ $foreign = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
+ $this->actingAs($foreign)->get($this->url.'?attempt_id='.$attempt->id)->assertNotFound();
+ supplyGrant($this->f['reviewer'], ['finance']);
+ DB::table('organization_memberships')->where('user_id', $this->f['reviewer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
+ $this->actingAs($this->f['reviewer'])->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->url.'?attempt_id='.$attempt->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false)->where('canPropose', false)->where('canAdopt', false));
+ $this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Não decidir em manutenção.'])->assertForbidden();
+ expect(RecommendationReview::query()->count())->toBe(0);
+});
+
test('recommendation rejects lower score incomplete matrix and stale admission', function (string $case) {
if ($case === 'incomplete') {
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->where('evaluator_id', $this->f['analyst']->id)->delete();
diff --git a/tests/Feature/InternalSupplyTraceTest.php b/tests/Feature/InternalSupplyTraceTest.php
new file mode 100644
index 0000000..713c087
--- /dev/null
+++ b/tests/Feature/InternalSupplyTraceTest.php
@@ -0,0 +1,129 @@
+f = suggestionFixture();
+ app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
+ supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
+ supplyGrant($this->f['receiver'], ['warehouse_request_review']);
+ supplyGrant($this->f['otherReceiver'], ['warehouse', 'warehouse_request_deliver']);
+ $this->actingAs($this->f['issuer']);
+ $policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]));
+ $this->actingAs($this->f['receiver']);
+ $policy = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
+ $this->actingAs($this->f['issuer']);
+ $rule = app(AutomaticPurchases::class)->save($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização sintética para integrar os módulos internos.',
+ 'data' => ['policy_key' => $policy->record_key, 'requester_id' => $this->f['issuer']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
+ $this->actingAs($this->f['receiver']);
+ app(AutomaticPurchases::class)->save($this->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'reason' => 'Conferência independente da regra no ensaio.', 'data' => []]);
+ $this->actingAs($this->f['issuer']);
+ $this->request = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id,
+ 'cost_center_id' => $this->f['r']->cost_center_id, 'priority' => 'normal', 'destination' => 'Oficina do ensaio', 'reason' => 'Necessidade interna de manutenção para o ensaio.',
+ 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '125.000', 'use' => 'internal']]]);
+ app(WarehouseFulfilment::class)->command($this->f['issuer'], $this->request->id, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'submit', 'partial_allowed' => true, 'reason' => 'Submissão efectiva do pedido para o ensaio.']);
+ app(AutomaticPurchases::class)->evaluate($this->f['issuer'], $this->f['stock']->id, $this->f['warehouse']->id);
+ $this->need = app(SupplyPlanning::class)->latest('auto_need')->sole();
+ $this->purchase = Requisition::query()->findOrFail($this->need->data['requisition_ids'][0]);
+});
+
+function internalTrace($test, ?User $actor = null): array
+{
+ $actor ??= $test->f['issuer'];
+ $test->actingAs($actor);
+
+ return app(InternalSupplyTrace::class)->forRequest($actor, $test->request, app(WarehouseFulfilment::class)->progress($test->request));
+}
+
+/** A documentary fixture version; not a new business approval or manual-link UI. */
+function internalTraceVersion($test, array $data, ?array $snapshot = null): SupplyRecord
+{
+ $old = app(SupplyPlanning::class)->latest('auto_need')->sole();
+ $copy = $old->replicate();
+ $copy->fill(['version' => $old->version + 1, 'data' => $data, 'snapshot' => $snapshot ?? $old->snapshot,
+ 'operation_key' => (string) Str::uuid(), 'input_hash' => hash('sha256', (string) Str::uuid()), 'created_at' => now()])->save();
+
+ return $copy;
+}
+
+test('native modules link warehouse shortage to the canonical purchase and its pending human approval', function () {
+ $trace = internalTrace($this);
+ expect($trace['groups'])->toHaveCount(1)->and($trace['groups'][0]['purchases'][0]['id'])->toBe($this->purchase->id)
+ ->and($trace['groups'][0]['purchases'][0]['status'])->toBe('draft')->and($trace['groups'][0]['unreserved_milli'])->toBe(125000);
+ app(RequisitionWorkflow::class)->submit($this->f['issuer'], $this->purchase);
+ $trace = internalTrace($this);
+ expect($trace['groups'][0]['purchases'][0]['status'])->toBe('pending')->and($trace['groups'][0]['purchases'][0]['next_task'])->not->toBeNull()
+ ->and($this->purchase->fresh()->steps->where('decision', 'approved'))->toHaveCount(0);
+ $this->get('/stock/requisicoes/'.$this->request->id)->assertOk()->assertInertia(fn (Assert $page) => $page->where('supplyTrace.groups.0.purchases.0.id', $this->purchase->id));
+ $this->get('/requisicoes/'.$this->purchase->id)->assertOk()->assertInertia(fn (Assert $page) => $page->where('automaticSource.origins.0.href', '/stock/requisicoes/'.$this->request->id));
+});
+
+test('trace queries reuse documents and do not create purchases decisions movements or reservations', function () {
+ $tables = ['requisitions', 'approval_steps', 'warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'supply_records', 'audit_logs', 'invoices', 'budget_entries'];
+ $before = [];
+ foreach ($tables as $table) {
+ $before[$table] = DB::table($table)->get()->toArray();
+ }
+ internalTrace($this);
+ internalTrace($this);
+ foreach ($tables as $table) {
+ expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
+ }
+});
+
+test('warehouse responsibilities do not acquire access to other peoples purchase processes or financial values', function () {
+ $trace = internalTrace($this, $this->f['otherReceiver']);
+ expect($trace['groups'][0]['purchases'])->toBe([])->and($trace['groups'][0]['restricted'])->toBeTrue()
+ ->and(json_encode($trace))->not->toContain($this->purchase->number)->not->toContain('unit_price')->not->toContain('total_minor');
+ $this->actingAs($this->f['otherReceiver'])->get('/stock/requisicoes/'.$this->request->id)->assertOk()
+ ->assertInertia(fn (Assert $page) => $page->where('supplyTrace.groups.0.restricted', true)->has('supplyTrace.groups.0.purchases', 0));
+});
+
+test('later shared needs do not retrospectively assign a future purchase to the old warehouse request', function () {
+ $new = internalTraceVersion($this, [...$this->need->data, 'requisition_ids' => [$this->purchase->id, $this->f['r']->id]], [...$this->need->snapshot, 'origins' => []]);
+ $trace = internalTrace($this);
+ expect($trace['groups'][0]['current_need_id'])->toBe($new->id)->and($trace['groups'][0]['purchases'])->toHaveCount(1)
+ ->and($trace['groups'][0]['purchases'][0]['id'])->toBe($this->purchase->id);
+});
+
+test('documentary receipt projection follows the linked issued order and respects the reversal engine', function () {
+ // Already issued order/receipt from the fixture, not the new automatic draft.
+ internalTraceVersion($this, [...$this->need->data, 'requisition_ids' => [$this->f['r']->id]]);
+ $trace = internalTrace($this);
+ $row = $trace['groups'][0]['purchases'][0]['orders'][0];
+ expect($row['id'])->toBe($this->f['order']->id)->and($row['effective_received_milli'])->toBe(120000)
+ ->and($row['receipts'][0]['reversed'])->toBeFalse();
+ $corrections = Mockery::mock(InventoryReversals::class)->makePartial();
+ $corrections->shouldReceive('corrections')->with('receipt')->andReturn([$row['receipts'][0]['item_id'] => $this->need]);
+ app()->instance(InventoryReversals::class, $corrections);
+ $trace = internalTrace($this);
+ expect($trace['groups'][0]['purchases'][0]['orders'][0]['effective_received_milli'])->toBe(0)
+ ->and($trace['groups'][0]['purchases'][0]['orders'][0]['receipts'][0]['reversed'])->toBeTrue()
+ ->and((int) DB::table('goods_receipt_items')->sum('accepted_milli'))->toBe(120000);
+});
+
+test('unrelated people and companies cannot read or link warehouse requests through a purchase', function () {
+ $outsider = User::factory()->create(['tenant_id' => $this->f['issuer']->tenant_id, 'department_id' => $this->f['issuer']->department_id]);
+ supplyGrant($outsider, ['procurement']);
+ $this->actingAs($outsider);
+ $origin = app(InternalSupplyTrace::class)->purchaseOrigin($outsider, $this->purchase);
+ expect($origin['origins'][0]['href'])->toBeNull();
+ $this->get('/stock/requisicoes/'.$this->request->id)->assertForbidden();
+ $foreign = receiptFixture();
+ supplyGrant($foreign['issuer'], ['admin']);
+ $this->actingAs($foreign['issuer'])->get('/stock/requisicoes/'.$this->request->id)->assertNotFound();
+});
diff --git a/tests/Feature/OrganizationActivationPreviewTest.php b/tests/Feature/OrganizationActivationPreviewTest.php
new file mode 100644
index 0000000..7f73a83
--- /dev/null
+++ b/tests/Feature/OrganizationActivationPreviewTest.php
@@ -0,0 +1,122 @@
+insertGetId(['tenant_id' => $tenant, 'name' => $name, 'permissions' => json_encode($permissions, JSON_THROW_ON_ERROR),
+ 'work_contexts' => json_encode($contexts, JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
+}
+
+function activationPreviewMember(int $tenant, int $user, int $profile, array $extra = []): void
+{
+ DB::table('organization_memberships')->insert(['tenant_id' => $tenant, 'user_id' => $user, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now(), ...$extra]);
+}
+
+beforeEach(function () {
+ $this->f = receiptFixture();
+ $this->admin = $this->f['issuer'];
+ $this->admin->forceFill(['purchasing_role' => PurchasingRole::Admin])->save();
+ $this->second = User::factory()->create(['tenant_id' => $this->admin->tenant_id, 'purchasing_role' => PurchasingRole::Requester]);
+ $this->profile = activationPreviewProfile($this->admin->tenant_id, 'Administração sintética', ['admin']);
+ foreach ([$this->admin, $this->second] as $user) {
+ activationPreviewMember($user->tenant_id, $user->id, $this->profile);
+ }
+ DB::table('organization_access')->insert(['tenant_id' => $this->admin->tenant_id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
+ $this->actingAs($this->admin);
+ $this->path = '/administracao/parametrizacao';
+});
+
+test('activation preview exposes access losses implicit request preparation and profile tasks without writing configuration', function () {
+ $before = [];
+ foreach (['organization_access', 'organization_profiles', 'organization_memberships', 'users', 'tenants', 'audit_logs', 'requisitions', 'purchase_orders'] as $table) {
+ $before[$table] = DB::table($table)->get()->toArray();
+ }
+ $this->get($this->path)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
+ ->assertInertia(fn (Assert $page) => $page->where('activationPreview.active', false)->where('activationPreview.version', 1)
+ ->where('activationPreview.permanent_administrators', 2)->where('activationPreview.losing_access', 2)
+ ->where('activationPreview.provisional_rules', true)->where('activationPreview.can_activate', false)
+ ->where('workflowTasks.procurement.1.key', 'procurement.prepare'));
+ $rows = collect(app(OrganizationActivationPreview::class)->build($this->admin)['users'])->keyBy('id');
+ expect($rows[$this->second->id]['current_permissions'])->toBe(['requester'])
+ ->and($rows[$this->second->id]['configured_permissions'])->toBe(['admin'])
+ ->and($rows[$this->second->id]['current_can_request'])->toBeTrue()->and($rows[$this->second->id]['configured_can_request'])->toBeFalse()
+ ->and($rows[$this->f['receiver']->id]['loses_access'])->toBeTrue();
+ foreach ($before as $table => $snapshot) {
+ expect(DB::table($table)->get()->toArray())->toEqual($snapshot);
+ }
+});
+
+test('activation preview agrees with the effective authority after explicit activation and preserves existing processes', function () {
+ DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false]);
+ $before = $this->f['r']->fresh()->toArray();
+ $preview = app(OrganizationActivationPreview::class)->build($this->admin);
+ expect($preview['can_activate'])->toBeTrue();
+ $this->post($this->path.'/activate', ['version' => $preview['version'], 'confirm' => true, 'reason' => 'Activação explícita apenas no ensaio sintético.'])->assertSessionHasNoErrors()->assertRedirect();
+ foreach ($preview['users'] as $person) {
+ $user = User::query()->findOrFail($person['id']);
+ expect(app(OrganizationAccess::class)->permissions($user))->toBe($person['configured_permissions'])
+ ->and(app(OrganizationAccess::class)->canRequest($user))->toBe($person['configured_can_request']);
+ }
+ $this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('activationPreview.active', true)->where('activationPreview.version', 2));
+ expect($this->f['r']->fresh()->toArray())->toBe($before)->and(DB::table('audit_logs')->where('action', 'organization.activate')->count())->toBe(1);
+});
+
+test('activation preview counts only permanent independent administrators and honours expiry and maintenance', function () {
+ DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false]);
+ DB::table('organization_memberships')->where('user_id', $this->second->id)->update(['expires_at' => now()->addHour()]);
+ $maintenance = activationPreviewProfile($this->admin->tenant_id, 'Consulta temporária sintética', ['warehouse']);
+ activationPreviewMember($this->admin->tenant_id, $this->f['receiver']->id, $maintenance, ['maintenance' => true, 'expires_at' => now()->addHour()]);
+ $preview = app(OrganizationActivationPreview::class)->build($this->admin);
+ $rows = collect($preview['users'])->keyBy('id');
+ expect($preview['permanent_administrators'])->toBe(1)->and($preview['can_activate'])->toBeFalse()
+ ->and($rows[$this->f['receiver']->id]['configured_maintenance_only'])->toBeTrue();
+ $this->postJson($this->path.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Não activar com administração permanente insuficiente.'])->assertUnprocessable()->assertJsonValidationErrors('administrators');
+ $this->travel(2)->hours();
+ $rows = collect(app(OrganizationActivationPreview::class)->build($this->admin)['users'])->keyBy('id');
+ expect($rows[$this->second->id]['configured_permissions'])->toBe([])
+ ->and($rows[$this->f['receiver']->id]['configured_permissions'])->toBe([])
+ ->and($rows[$this->f['receiver']->id]['configured_maintenance_only'])->toBeFalse()
+ ->and(app(OrganizationAccess::class)->active($this->admin->tenant_id))->toBeFalse();
+});
+
+test('activation preview uses the same company licence context and functional intersection as runtime', function () {
+ DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false, 'work_context_policy' => json_encode(['revision' => 1, 'enabled' => ['procurement.internal', 'people.internal']], JSON_THROW_ON_ERROR)]);
+ config(['work_context.entitlements.'.$this->admin->tenant_id => ['procurement.internal']]);
+ $profile = activationPreviewProfile($this->admin->tenant_id, 'Preparação nas áreas autorizadas', ['requester', 'people_service_prepare'], ['procurement.internal', 'people.internal']);
+ activationPreviewMember($this->admin->tenant_id, $this->f['receiver']->id, $profile);
+ $preview = app(OrganizationActivationPreview::class)->build($this->admin);
+ $rows = collect($preview['users'])->keyBy('id');
+ expect($rows[$this->f['receiver']->id]['configured_contexts'])->toBe(['procurement.internal']);
+ $this->post($this->path.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Activação sintética respeitando os contextos contratados.'])->assertSessionHasNoErrors();
+ expect(app(WorkContext::class)->available($this->f['receiver']))->toBe($rows[$this->f['receiver']->id]['configured_contexts']);
+ $this->actingAs($this->f['receiver'])->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertForbidden();
+});
+
+test('activation preview cannot be read by warehouse users or expose another company configuration', function () {
+ $this->actingAs($this->f['receiver'])->get($this->path)->assertForbidden();
+ $tenant = Tenant::query()->create(['name' => 'Outra organização sintética']);
+ $foreign = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Admin]);
+ $this->actingAs($foreign)->get($this->path)->assertInertia(fn (Assert $page) => $page->has('activationPreview.users', 1)
+ ->where('activationPreview.users.0.id', $foreign->id)->where('activationPreview.permanent_administrators', 0));
+});
+
+test('activation preview is informational and cannot replace confirmation or an up to date version', function () {
+ DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false]);
+ $preview = app(OrganizationActivationPreview::class)->build($this->admin);
+ $this->postJson($this->path.'/activate', ['version' => 1, 'reason' => 'Não activar pela mera leitura do diagnóstico.'])->assertUnprocessable()->assertJsonValidationErrors('confirm');
+ app(OrganizationConfiguration::class)->execute($this->admin, 'profile', ['version' => 1, 'name' => 'Consulta sintética', 'permissions' => ['warehouse_request_read']]);
+ $this->postJson($this->path.'/activate', ['version' => $preview['version'], 'confirm' => true, 'reason' => 'Uma versão anterior não confirma alterações posteriores.'])->assertUnprocessable()->assertJsonValidationErrors('version');
+ expect(app(OrganizationAccess::class)->active($this->admin->tenant_id))->toBeFalse();
+ $this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('activationPreview.version', 2));
+});
diff --git a/tests/Feature/PayableHistoryImportTest.php b/tests/Feature/PayableHistoryImportTest.php
index 6039b40..1ff13cf 100644
--- a/tests/Feature/PayableHistoryImportTest.php
+++ b/tests/Feature/PayableHistoryImportTest.php
@@ -151,8 +151,8 @@ test('imported ledger is append-only and disabling the history cannot silently r
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Preservar responsabilidade única', (string) Str::uuid());
- expect(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0]))->toThrow(QueryException::class);
- expect(fn () => DB::table('pa_imported_payments')->delete())->toThrow(QueryException::class);
+ expect(fn () => DB::transaction(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0])))->toThrow(QueryException::class);
+ expect(fn () => DB::transaction(fn () => DB::table('pa_imported_payments')->delete()))->toThrow(QueryException::class);
config(['common-payables.history_enabled' => false]);
expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class);
config(['common-payables.enabled' => false]);
diff --git a/tests/Feature/PayablePaymentDecisionTest.php b/tests/Feature/PayablePaymentDecisionTest.php
index 6dfea07..e85a82c 100644
--- a/tests/Feature/PayablePaymentDecisionTest.php
+++ b/tests/Feature/PayablePaymentDecisionTest.php
@@ -7,6 +7,7 @@ use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
+use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\Payables;
@@ -107,3 +108,16 @@ test('a full confirmation leaves zero balance and prevents another confirmation'
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(0);
expect(fn () => $workflow->review($this->f['validator'], $this->payment, 'confirmed', 'Saldo já liquidado'))->toThrow(ValidationException::class);
});
+
+test('task inbox delegates imported payment authority without exposing or deciding the ledger', function () {
+ $before = [DB::table('pa_payment_decisions')->count(), DB::table('audit_logs')->count(), $this->frozen];
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2));
+ config(['common-payables.history_decisions_enabled' => false]);
+ $this->get('/requisicoes/tarefas?module=finance')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ config(['common-payables.history_decisions_enabled' => true]);
+ DB::table('pa_access')->where('user_id', $this->f['validator']->id)->where('action', 'validate')->delete();
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ config(['common-payables.source_instance' => 'different-installation']);
+ $this->get('/requisicoes/tarefas?module=finance')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect([DB::table('pa_payment_decisions')->count(), DB::table('audit_logs')->count(), Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'payments' => DB::table('pa_imported_payments')->get()->toArray()])])->toEqual($before);
+});
diff --git a/tests/Feature/PaymentIntegrationTest.php b/tests/Feature/PaymentIntegrationTest.php
new file mode 100644
index 0000000..33112e4
--- /dev/null
+++ b/tests/Feature/PaymentIntegrationTest.php
@@ -0,0 +1,87 @@
+f = paymentFixture();
+ $this->actingAs($this->f['author']);
+ $this->payment = app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], $this->f['payment_data']);
+});
+
+test('payment task routes the independent reviewer to the exact record without exposing evidence in the task', function () {
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->f['validator']);
+ $before = [DB::table('external_payments')->get()->toArray(), DB::table('audit_logs')->count(), BudgetConsumption::query()->sum('amount_minor')];
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.href', '/pagamentos?payment_id='.$this->payment->id.'#payment-'.$this->payment->id)
+ ->missing('tasks.data.0.external_reference')->missing('tasks.data.0.evidence_reference')->missing('tasks.data.0.bank_snapshot'));
+ expect([DB::table('external_payments')->get()->toArray(), DB::table('audit_logs')->count(), BudgetConsumption::query()->sum('amount_minor')])->toEqual($before);
+ $this->get('/pagamentos?payment_id='.$this->payment->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')
+ ->assertInertia(fn (Assert $page) => $page->where('focusPaymentId', $this->payment->id)->where('payments.total', 1)->where('payments.data.0.canReview', true));
+ $this->post('/pagamentos/'.$this->payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Conferência independente sintética', 'independently_verified' => true])->assertRedirect();
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000)
+ ->and(BudgetConsumption::query()->sum('amount_minor'))->toEqual($before[2]);
+});
+
+test('payment focus stays scoped to its invoice and process documents link to its exact record', function () {
+ $this->get('/pagamentos?invoice_id='.$this->f['invoice']->id)->assertInertia(fn (Assert $page) => $page->where('focusInvoiceId', $this->f['invoice']->id)->has('invoices', 1)->where('payments.total', 1));
+ $this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page
+ ->where('stages.7.href', '/facturas?purchase_order_id='.$this->f['order']->id)
+ ->where('documents', fn ($documents) => collect($documents)->contains('href', '/pagamentos?payment_id='.$this->payment->id.'#payment-'.$this->payment->id)));
+});
+
+test('payment task and review form respect conflicts and revoked finance authority', function () {
+ $this->actingAs($this->f['validator']);
+ $guard = Mockery::mock(ConflictGuard::class)->makePartial();
+ $guard->shouldReceive('processBlocked')->andReturn(true);
+ app()->instance(ConflictGuard::class, $guard);
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/pagamentos')->assertInertia(fn (Assert $page) => $page->where('payments.data.0.canReview', false));
+ app()->forgetInstance(ConflictGuard::class);
+ $this->f['validator']->forceFill(['purchasing_role' => PurchasingRole::Admin])->save();
+ $this->actingAs($this->f['validator']->fresh());
+ $this->get('/pagamentos')->assertInertia(fn (Assert $page) => $page->where('payments.data.0.canReview', false)->where('payments.data.0.external_reference', null));
+ $this->post('/pagamentos/'.$this->payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden();
+});
+
+test('bank revision does not hide a pending rejection but confirmation still rechecks the bank', function () {
+ $this->actingAs($this->f['issuer']);
+ app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $this->f['supplier'], [...$this->f['bank_data'], 'previous_id' => $this->f['bank']->id, 'account_identifier' => 'TEST-NEW-VERSION']);
+ $this->actingAs($this->f['validator']);
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
+ $data = ['decision' => 'confirmed', 'reason' => 'Conferência sintética', 'independently_verified' => true];
+ $this->postJson('/pagamentos/'.$this->payment->id.'/conferir', $data)->assertUnprocessable();
+ expect($this->payment->fresh()->status)->toBe('pending');
+ $this->post('/pagamentos/'.$this->payment->id.'/conferir', [...$data, 'decision' => 'rejected'])->assertRedirect();
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+});
+
+test('payment and invoice focus cannot cross companies', function () {
+ $other = invoiceFixture();
+ $foreign = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
+ $this->actingAs($foreign)->getJson('/pagamentos?payment_id='.$this->payment->id)->assertUnprocessable();
+ $this->getJson('/pagamentos?invoice_id='.$this->f['invoice']->id)->assertUnprocessable();
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.id', 'invoice:'.$other['invoice']->id));
+});
+
+test('maintenance finance can consult payment without receiving evidence or decision authority', function () {
+ $actor = $this->f['validator'];
+ supplyGrant($actor, ['finance']);
+ DB::table('organization_memberships')->where('user_id', $actor->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
+ $this->actingAs($actor);
+ $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/pagamentos')->assertInertia(fn (Assert $page) => $page->where('canManage', false)->where('payments.data.0.canReview', false)->where('payments.data.0.evidence_reference', null));
+ $this->post('/pagamentos/'.$this->payment->id.'/conferir', ['decision' => 'rejected', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden();
+ expect($this->payment->fresh()->status)->toBe('pending');
+});
diff --git a/tests/Feature/PilotAccessProposalTest.php b/tests/Feature/PilotAccessProposalTest.php
new file mode 100644
index 0000000..39d0bf7
--- /dev/null
+++ b/tests/Feature/PilotAccessProposalTest.php
@@ -0,0 +1,76 @@
+getDriverName() === 'sqlite' && $connection->getDatabaseName() === ':memory:')
+ || ($connection->getDriverName() === 'pgsql' && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $connection->getDatabaseName()));
+ if (! app()->environment('testing') || ! $isolated) {
+ throw new LogicException('Proposta proibida fora de ensaio descartável.');
+ }
+ $proposal = json_decode(file_get_contents(base_path('docs/proofs/pilot-access-proposal.json')), true, 64, JSON_THROW_ON_ERROR);
+ expect($proposal['status'])->toBe('pending_owner_authorization')->and($proposal['environment'])->toBe('http://127.0.0.1:8005');
+ $company = Tenant::query()->create(['name' => 'Simulação descartável dos acessos do piloto',
+ 'work_context_policy' => ['revision' => 1, 'enabled' => $proposal['enabled_contexts']]]);
+ $department = Department::query()->create(['tenant_id' => $company->id, 'name' => 'Departamento sintético']);
+ config(['work_context.entitlements.'.$company->id => $proposal['enabled_contexts']]);
+ $users = [];
+ $before = [];
+ foreach ($proposal['users'] as $person) {
+ $user = User::factory()->create(['tenant_id' => $company->id, 'department_id' => $department->id,
+ 'name' => 'Simulação — '.$person['name'], 'purchasing_role' => PurchasingRole::from($person['current_permissions'][0])]);
+ $users[$person['id']] = $user;
+ $before[$person['id']] = ['permissions' => app(OrganizationAccess::class)->permissions($user),
+ 'contexts' => app(WorkContext::class)->available($user), 'requester' => app(OrganizationAccess::class)->canRequest($user),
+ 'department_scoped' => app(OrganizationAccess::class)->departmentScoped($user)];
+ expect($before[$person['id']]['contexts'])->toBe($person['current_contexts']);
+ }
+ $actor = $users[8];
+ $this->actingAs($actor);
+ $base = '/administracao/parametrizacao';
+ DB::table('organization_access')->insert(['tenant_id' => $company->id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
+ $version = 1;
+ $profiles = [];
+ foreach ($proposal['profiles'] as $profile) {
+ $this->postJson($base.'/profile', ['version' => $version++, 'name' => $profile['name'], 'permissions' => $profile['permissions'], 'work_contexts' => $profile['work_contexts']])->assertSessionHasNoErrors()->assertRedirect();
+ $profiles[$profile['key']] = DB::table('organization_profiles')->where('name', $profile['name'])->sole()->id;
+ }
+ foreach ($proposal['users'] as $person) {
+ $ids = array_map(fn ($key) => $profiles[$key], $person['profile_keys']);
+ $this->postJson($base.'/assign', ['version' => $version++, 'user_id' => $users[$person['id']]->id, 'profile_ids' => $ids,
+ 'maintenance' => false, 'expires_at' => null, 'reason' => 'Ensaiar proposta sintética apenas na base descartável.'])->assertSessionHasNoErrors()->assertRedirect();
+ }
+ $rules = $proposal['proposed_rules'];
+ $this->postJson($base.'/rules', ['version' => $version++, 'first_limit' => $rules['first_limit'], 'second_limit' => $rules['second_limit'],
+ 'includes_tax' => $rules['includes_tax'], 'reason' => 'Conferência sintética das mesmas alçadas, sem alterar os valores.'])->assertSessionHasNoErrors()->assertRedirect();
+ $this->postJson($base.'/work_contexts', ['version' => $version++, 'enabled' => $proposal['enabled_contexts'], 'confirm' => true,
+ 'reason' => 'Preservar as opções existentes apenas na simulação descartável.'])->assertSessionHasNoErrors()->assertRedirect();
+ $preview = app(OrganizationActivationPreview::class)->build($actor);
+ expect($preview['can_activate'])->toBeTrue()->and($preview['losing_access'])->toBe(0)->and($preview['permanent_administrators'])->toBe(2);
+ foreach ($users as $sourceId => $user) {
+ expect(app(OrganizationAccess::class)->permissions($user))->toBe($before[$sourceId]['permissions']);
+ }
+ $this->postJson($base.'/activate', ['version' => $version, 'confirm' => true, 'reason' => 'Activar a proposta exclusivamente no ensaio descartável.'])->assertSessionHasNoErrors()->assertRedirect();
+ foreach ($proposal['users'] as $person) {
+ $user = $users[$person['id']];
+ $permissions = app(OrganizationAccess::class)->permissions($user);
+ expect($permissions)->toEqualCanonicalizing($person['planned_permissions'])
+ ->and(array_values(array_diff($person['current_permissions'], $permissions)))->toBe([])
+ ->and(app(WorkContext::class)->available($user))->toBe($before[$person['id']]['contexts'])
+ ->and(app(OrganizationAccess::class)->canRequest($user))->toBe($before[$person['id']]['requester'])
+ ->and(app(OrganizationAccess::class)->departmentScoped($user))->toBe($before[$person['id']]['department_scoped']);
+ }
+ $this->get($base)->assertInertia(fn (Assert $page) => $page->where('activationPreview.active', true)->where('activationPreview.losing_access', 0));
+ expect(DB::table('users')->count())->toBe(12)->and(DB::table('requisitions')->count())->toBe(0)
+ ->and(DB::table('purchase_orders')->count())->toBe(0)->and(DB::table('invoices')->count())->toBe(0)
+ ->and(DB::table('pa_access')->count())->toBe(0);
+});
diff --git a/tests/Feature/PurchaseAwardOrderTest.php b/tests/Feature/PurchaseAwardOrderTest.php
index a3b2a0d..5a7b825 100644
--- a/tests/Feature/PurchaseAwardOrderTest.php
+++ b/tests/Feature/PurchaseAwardOrderTest.php
@@ -45,6 +45,8 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
+require_once __DIR__.'/../Support/SupplyFixture.php';
+
function awardPrices(object $test, int $firstPrice, int $taxRate = 0): void
{
$test->r->items()->update(['tax_rate' => $taxRate]);
@@ -776,3 +778,65 @@ test('legacy award keeps null admission basis and refuses later adoption', funct
expect(AdmissionAdoption::query()->count())->toBe(0);
issuePurchaseOrder($this, $award);
});
+
+test('task inbox follows every final award approval and hands approved issuance back to procurement', function () {
+ awardPrices($this, 30000000);
+ $award = selectPurchaseAward($this);
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ foreach ([$this->head, $this->secondBuyer, $this->finance] as $reviewer) {
+ $this->actingAs($reviewer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.task', 'award_approve')->where('tasks.data.0.href', $this->awardUrl));
+ $this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ }
+ $this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'order_issue_review'));
+ $this->get($this->awardUrl)->assertInertia(fn (Assert $page) => $page->where('canIssue', true));
+ $this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
+ expect(PurchaseOrder::query()->count())->toBe(1);
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+});
+
+test('task inbox respects pending cancellation and consultation leaves award budget and audit untouched', function () {
+ enablePurchaseCancellation($this);
+ $award = selectPurchaseAward($this);
+ $this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
+ $this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade fictícia em conferência'])->assertSessionHasNoErrors();
+ $before = [$award->fresh()->toArray(), $award->approvals()->get()->toArray(), BudgetEntry::query()->get()->toArray(), AuditLog::query()->count()];
+ $this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->awardUrl)->assertInertia(fn (Assert $page) => $page->where('canDecide', false));
+ expect([$award->fresh()->toArray(), $award->approvals()->get()->toArray(), BudgetEntry::query()->get()->toArray(), AuditLog::query()->count()])->toEqual($before);
+ $cancel = AwardCancellation::query()->sole();
+ $this->actingAs($this->finance)->post('/cancelamentos/'.$cancel->id.'/decidir', ['decision' => 'rejected', 'reason' => 'Manter o processo sintético original'])->assertSessionHasNoErrors();
+ $this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
+});
+
+test('task inbox cannot grant an award decision to maintenance or a revoked department role', function () {
+ $award = selectPurchaseAward($this);
+ supplyGrant($this->head, ['department_head']);
+ DB::table('organization_memberships')->where('user_id', $this->head->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
+ $this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get($this->awardUrl)->assertInertia(fn (Assert $page) => $page->where('canDecide', false));
+ $this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
+ DB::table('organization_memberships')->where('user_id', $this->head->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]);
+ $this->actingAs($this->head->fresh())->get('/requisicoes/tarefas?module=purchases')->assertForbidden();
+ expect($award->fresh()->status)->toBe('pending')->and(PurchaseOrder::query()->count())->toBe(0);
+});
+
+test('task inbox issuance review does not override a proposal that expired after approval', function () {
+ $award = selectPurchaseAward($this);
+ approvePurchaseAward($this, $award);
+ $this->quotes[0]->update(['valid_until' => today()->subDay()]);
+ $this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'order_issue_review'));
+ $this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors();
+ expect(PurchaseOrder::query()->count())->toBe(0)->and($award->fresh()->status)->toBe('approved');
+});
+
+test('quotation tasks hand a ready comparison to adjudication without reopening rejected awards', function () {
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'comparison_review'));
+ $award = selectPurchaseAward($this);
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'award_approve'));
+ $this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'rejected', 'comment' => 'Devolver ao percurso autorizado de revisão.'])->assertSessionHasNoErrors();
+ $this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('canCreate', false)->where('canEvaluate', false));
+});
diff --git a/tests/Feature/ReceiptAdapterReplayTest.php b/tests/Feature/ReceiptAdapterReplayTest.php
new file mode 100644
index 0000000..ae63b6c
--- /dev/null
+++ b/tests/Feature/ReceiptAdapterReplayTest.php
@@ -0,0 +1,24 @@
+assertMatchesRegularExpression('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name);
+ $this->assertSame('stock_receipt_qa_'.substr($i->name,-12),$i->role);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster);
+ $root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);
+ $proof=json_decode(file_get_contents($root.'/receipt-results.json'),true,512,JSON_THROW_ON_ERROR);
+ $row=DB::table('stock_movements')->where('tenant_id',$proof['link']['tenant'])->where('id',$proof['link']['movement'])->first();$this->assertNotNull($row);
+ $actor=User::findOrFail($row->moved_by);$this->actingAs($actor);$count=DB::table('stock_movements')->count();
+ $hash=json_decode($row->snapshot,true)['inventory_source_hash'];
+ $result=DB::transaction(function()use($actor,$row,$hash){app(ConflictGuard::class)->lockCompany($actor);return app(ReceiptStockMovement::class)->for($actor)->write($row->goods_receipt_item_id,$hash);});
+ $this->assertTrue($result['replayed']);$this->assertSame((int)$row->id,$result['movement_id']);$this->assertSame($count,DB::table('stock_movements')->count());
+ }
+}
diff --git a/tests/Feature/SupplierQuotationTest.php b/tests/Feature/SupplierQuotationTest.php
index 38fec38..fbca6e3 100644
--- a/tests/Feature/SupplierQuotationTest.php
+++ b/tests/Feature/SupplierQuotationTest.php
@@ -12,8 +12,11 @@ use App\Models\User;
use App\Services\ComparisonPolicy;
use App\Services\ProposalComparison;
use App\Services\RequisitionWorkflow;
+use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
+require_once __DIR__.'/../Support/SupplyFixture.php';
+
function supplierPayload(string $nuit = '123456789'): array
{
return ['name' => 'Fornecedor de teste', 'nuit' => $nuit, 'contact' => 'Contacto', 'email' => 'test@example.com', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário teste', 'document_references' => 'Alvará e declaração verificados no processo 123.', 'diligence_notes' => 'Documentação conferida e sem impedimentos.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')];
@@ -149,7 +152,7 @@ test('configured comparison uses the preserved minimum and weights and retains i
$this->tenant->update(['comparison_policy' => ComparisonPolicy::DEFAULTS]);
expect(app(ProposalComparison::class)->inspect($this->r)['fingerprint'])->toBe($before['fingerprint']);
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->where('comparisonPolicy.minimum_evaluators', 1)->where('ready', true));
- $this->owner->update(['purchasing_role' => PurchasingRole::Finance]);
+ $this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
$this->actingAs($this->owner)->post("/cotacoes/{$quote->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'O solicitante continua impedido.', 'no_conflict' => true])->assertForbidden();
});
@@ -178,3 +181,131 @@ test('supplier quote and evaluation routes isolate companies', function () {
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertNotFound();
$this->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Tentativa de outro inquilino.', 'no_conflict' => true])->assertNotFound();
});
+
+function quotationTaskRegister(object $test): Quotation
+{
+ $test->actingAs($test->buyer)->post('/requisicoes/'.$test->r->id.'/cotacoes', $test->quote)->assertSessionHasNoErrors();
+
+ return Quotation::query()->sole();
+}
+
+function quotationTaskEvaluation(): array
+{
+ return ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação sintética fundamentada.', 'no_conflict' => true];
+}
+
+test('quotation tasks link missing proposals and each independent evaluation to the existing forms', function () {
+ $path = '/requisicoes/'.$this->r->id.'/cotacoes';
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'quotation_prepare'));
+ $this->get($path.'?prepare=1')->assertInertia(fn (Assert $page) => $page->where('prepare', true)->where('canCreate', true)->where('requisition.items.0.id', $this->item->id));
+ $quote = quotationTaskRegister($this);
+ $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.href', $path.'?quotation_id='.$quote->id.'#quotation-'.$quote->id)->missing('tasks.data.0.total_minor')->missing('tasks.data.0.evaluations'));
+ $this->get($path.'?quotation_id='.$quote->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')
+ ->assertInertia(fn (Assert $page) => $page->where('focusQuotationId', $quote->id)->where('quotations.0.canEvaluate', true));
+ $this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2));
+ $this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'quotation_prepare'));
+});
+
+test('quotation tasks use preserved comparison minima and hand the ready result to procurement', function () {
+ $policy = [...ComparisonPolicy::DEFAULTS, 'revision' => 1, 'configured' => true, 'minimum_proposals' => 1, 'minimum_evaluators' => 1];
+ $this->r->update(['comparison_policy' => $policy]);
+ $quote = quotationTaskRegister($this);
+ $this->actingAs($this->finance)->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
+ $this->tenant->update(['comparison_policy' => [...$policy, 'revision' => 2, 'minimum_proposals' => 5, 'minimum_evaluators' => 5]]);
+ $this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.task', 'comparison_review')->where('tasks.data.0.href', '/requisicoes/'.$this->r->id.'/adjudicacao'));
+ $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect(DB::table('purchase_awards')->count())->toBe(0)->and(DB::table('purchase_orders')->count())->toBe(0);
+});
+
+test('quotation tasks retain comparison impediments without asking for replacement data', function () {
+ $this->r->update(['comparison_policy' => [...ComparisonPolicy::DEFAULTS, 'revision' => 1, 'configured' => true, 'minimum_proposals' => 1]]);
+ $quote = quotationTaskRegister($this);
+ $quote->update(['valid_until' => today()->subDay()]);
+ $before = [DB::table('quotations')->get()->toArray(), DB::table('proposal_evaluations')->count(), DB::table('audit_logs')->count()];
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
+ ->where('tasks.data.0.task', 'quotation_check')->where('tasks.data.0.href', '/requisicoes/'.$this->r->id.'/cotacoes'));
+ $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/requisicoes/'.$this->r->id.'/cotacoes?quotation_id='.$quote->id)->assertInertia(fn (Assert $page) => $page->where('quotations.0.canEvaluate', false));
+ expect([DB::table('quotations')->get()->toArray(), DB::table('proposal_evaluations')->count(), DB::table('audit_logs')->count()])->toEqual($before);
+ $this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasErrors('evaluation');
+});
+
+test('quotation tasks respect requester independence department scope and quotation focus ownership', function () {
+ $quote = quotationTaskRegister($this);
+ $this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
+ $this->actingAs($this->owner)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertOk()->assertInertia(fn (Assert $page) => $page->where('quotations.0.canEvaluate', false));
+ $department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outra unidade fictícia']);
+ $head = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
+ $this->actingAs($head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertNotFound();
+ $other = $this->r->replicate();
+ $other->number = 'REQ-OUTRA-ORIGEM';
+ $other->save();
+ $alien = $quote->replicate();
+ $alien->requisition_id = $other->id;
+ $alien->save();
+ $this->actingAs($this->finance)->getJson('/requisicoes/'.$this->r->id.'/cotacoes?quotation_id='.$alien->id)->assertUnprocessable();
+});
+
+test('quotation tasks never keep stale finance or procurement authority during a command', function () {
+ $quote = quotationTaskRegister($this);
+ $this->actingAs($this->finance);
+ DB::table('users')->where('id', $this->finance->id)->update(['purchasing_role' => 'warehouse']);
+ $this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertForbidden();
+ $this->actingAs($this->buyer);
+ DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => 'warehouse']);
+ $this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'reference' => 'NOVA-TENTATIVA'])->assertForbidden();
+ expect(Quotation::query()->count())->toBe(1)->and(DB::table('proposal_evaluations')->count())->toBe(0);
+});
+
+test('quotation tasks exclude maintenance from preparation and evaluation', function (string $function) {
+ quotationTaskRegister($this);
+ $actor = $function === 'procurement' ? $this->buyer : $this->finance;
+ supplyGrant($actor, [$function]);
+ DB::table('organization_memberships')->where('user_id', $actor->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
+ $this->actingAs($actor)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('canCreate', false)->where('canEvaluate', false)->where('quotations.0.canEvaluate', false));
+})->with(['procurement', 'finance']);
+
+test('quotation tasks keep older market consultations visible through pagination and search', function () {
+ $this->r->forceFill(['created_at' => now()->subMonths(2)])->save();
+ for ($index = 1; $index < 30; $index++) {
+ $request = $this->r->replicate();
+ $request->number = 'REQ-NEW-'.$index;
+ $request->created_at = now()->subDay();
+ $request->save();
+ }
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 25)->where('tasks.data.0.reference', $this->r->number));
+ $this->get('/requisicoes/tarefas?module=purchases&page=2')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 5));
+ $this->get('/requisicoes/tarefas?module=purchases&search=REQ-TEST')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.reference', $this->r->number));
+});
+
+test('quotation tasks and forms immediately respect a declared participation conflict', function () {
+ $quote = quotationTaskRegister($this);
+ $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
+ $this->post('/conflitos', ['scope' => 'process_supplier', 'supplier_id' => $this->supplier->id, 'requisition_id' => $this->r->id,
+ 'has_conflict' => true, 'known_since' => today()->toDateString(), 'reason' => 'Relação sintética que impede a participação.'])->assertSessionHasNoErrors();
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->get('/requisicoes/'.$this->r->id.'/cotacoes?quotation_id='.$quote->id)->assertInertia(fn (Assert $page) => $page->where('quotations.0.canEvaluate', false)->where('quotations.0.conflict_blocked', true));
+ $this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasErrors('conflict');
+ expect(DB::table('proposal_evaluations')->count())->toBe(0);
+});
+
+test('quotation tasks recheck qualified evaluator authority without replacing the stored evaluation', function () {
+ $this->r->update(['comparison_policy' => [...ComparisonPolicy::DEFAULTS, 'revision' => 1, 'configured' => true, 'minimum_proposals' => 1, 'minimum_evaluators' => 1]]);
+ $quote = quotationTaskRegister($this);
+ $this->actingAs($this->finance)->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
+ $history = DB::table('proposal_evaluations')->get()->toArray();
+ DB::table('users')->where('id', $this->finance->id)->update(['purchasing_role' => 'warehouse']);
+ $this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'quotation_evaluate'));
+ expect(DB::table('proposal_evaluations')->get()->toArray())->toEqual($history);
+ $this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
+ expect(DB::table('proposal_evaluations')->count())->toBe(2);
+ $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'comparison_review'));
+});
diff --git a/tests/Feature/WarehouseExchangeTest.php b/tests/Feature/WarehouseExchangeTest.php
new file mode 100644
index 0000000..c2de65a
--- /dev/null
+++ b/tests/Feature/WarehouseExchangeTest.php
@@ -0,0 +1,212 @@
+f = receiptFixture('100.000');
+ $this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
+ supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
+ supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
+ supplyGrant($this->reviewer, ['warehouse_request_review']);
+ $this->actingAs($this->f['receiver']);
+ app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
+ $this->actingAs($this->f['issuer']);
+ $this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(),
+ 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
+ 'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal',
+ 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'internal', 'equipment_id' => null]]]);
+});
+
+function exchangeCommand($test, $actor, string $action, array $extra = []): WarehouseRequestEvent
+{
+ $test->actingAs($actor);
+
+ return app(WarehouseFulfilment::class)->command($actor, $test->r->id, [
+ 'operation_key' => (string) Str::uuid(), 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'],
+ 'action' => $action, 'reason' => 'Intervenção sintética do ensaio de integração.', ...$extra]);
+}
+
+function exchangeDeliver($test, string $quantity = '1.500'): WarehouseRequestEvent
+{
+ exchangeCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
+ exchangeCommand($test, $test->reviewer, 'authorize', ['partial_allowed' => true]);
+
+ return exchangeCommand($test, $test->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => $quantity]]]);
+}
+
+test('warehouse exchange publishes only physical facts with original identifiers and stable replay content', function () {
+ $url = '/stock/integracao/entregas';
+ $this->getJson($url)->assertOk()->assertJsonCount(0, 'items');
+ $delivery = exchangeDeliver($this);
+ $first = $this->getJson($url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
+ ->assertJsonPath('schema', WarehouseExchange::SCHEMA)->assertJsonCount(1, 'items')
+ ->assertJsonPath('items.0.lines.0.quantity_milli', 1500)
+ ->assertJsonPath('items.0.lines.0.stock_operation_id', $delivery->data['lines'][0]['stock_operation_id'])
+ ->assertJsonPath('items.0.request.document_type', 'internal_warehouse_request')
+ ->assertJsonPath('items.0.request.destination', 'Oficina interna')->json('items.0');
+ $payload = $first;
+ unset($payload['payload_sha256']);
+ expect(hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)))->toBe($first['payload_sha256']);
+ // Current register changes must not rewrite the original item or requester.
+ $this->f['stock']->update(['name' => 'Nome posterior do cadastro']);
+ $this->f['issuer']->update(['name' => 'Nome posterior da pessoa']);
+ exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
+ $response = $this->getJson($url)->assertOk()->assertJsonCount(2, 'items');
+ expect($response->json('items.0'))->toBe($first);
+ $response->assertJsonPath('items.1.action', 'receive')->assertJsonCount(0, 'items.1.lines')
+ ->assertJsonPath('items.1.delivery_event_key', $first['idempotency_key']);
+});
+
+test('warehouse exchange resumes old pending pages and later polls without skipping or duplicating facts', function () {
+ $delivery = exchangeDeliver($this);
+ exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
+ $first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->assertJsonPath('has_more', true)->json();
+ $next = '/stock/integracao/entregas?limit=1&cursor='.urlencode($first['next_cursor']);
+ exchangeCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
+ $this->actingAs($this->f['issuer']);
+ $second = $this->getJson($next)->assertOk()->assertJsonPath('items.0.action', 'receive')->assertJsonPath('has_more', true)->json();
+ expect($second['items'][0]['event_id'])->toBeGreaterThan($first['items'][0]['event_id']);
+ $third = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($second['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('has_more', false)->json();
+ $empty = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($third['next_cursor']))->assertOk()->assertJsonCount(0, 'items')->json();
+ $last = WarehouseRequestEvent::query()->where('action', 'deliver')->latest('id')->firstOrFail();
+ exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
+ $this->getJson('/stock/integracao/entregas?cursor='.urlencode($empty['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.action', 'receive');
+});
+
+test('warehouse exchange reads leave business ledgers profiles and audit unchanged', function () {
+ exchangeDeliver($this);
+ $tables = ['stock_movements', 'stock_operations', 'warehouse_reservations', 'warehouse_requests', 'warehouse_request_events', 'audit_logs', 'requisitions', 'organization_memberships'];
+ $before = [];
+ foreach ($tables as $table) {
+ $before[$table] = DB::table($table)->get()->toArray();
+ }
+ $this->getJson('/stock/integracao/entregas')->assertOk();
+ $this->getJson('/stock/integracao/entregas')->assertOk();
+ foreach ($tables as $table) {
+ expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
+ }
+});
+
+test('warehouse exchange rejects corrupt cross user cursors invalid limits and revoked access', function () {
+ exchangeDeliver($this);
+ $cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
+ $this->getJson('/stock/integracao/entregas?cursor=invalid')->assertUnprocessable()->assertJsonValidationErrors('cursor');
+ $this->getJson('/stock/integracao/entregas?limit=101')->assertUnprocessable();
+ $this->getJson('/stock/integracao/entregas?limit=0')->assertUnprocessable();
+ $this->actingAs($this->f['issuer'])->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable();
+ DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->delete();
+ $this->getJson('/stock/integracao/entregas')->assertForbidden();
+});
+
+test('warehouse exchange respects department ownership and tenant boundaries', function () {
+ exchangeDeliver($this);
+ $department = Department::create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
+ $outsider = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
+ supplyGrant($outsider, ['warehouse_request_review', 'warehouse_request_prepare']);
+ $this->actingAs($outsider)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
+ $this->actingAs($this->reviewer)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items');
+ $foreign = receiptFixture('50.000');
+ supplyGrant($foreign['issuer'], ['admin']);
+ $this->actingAs($foreign['issuer'])->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
+});
+
+test('warehouse exchange requires an authenticated account with functional access', function () {
+ auth('web')->logout();
+ $this->getJson('/stock/integracao/entregas')->assertUnauthorized();
+ $this->actingAs($this->f['otherReceiver'])->getJson('/stock/integracao/entregas')->assertForbidden();
+});
+
+test('warehouse exchange rejects old cursors when functional scope expands and publishes no mere reservation', function () {
+ exchangeCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
+ exchangeCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
+ $response = $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
+ $cursor = $response->json('next_cursor');
+ supplyGrant($this->reviewer, ['warehouse_request_read']);
+ $this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable()->assertJsonValidationErrors('cursor');
+ $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
+});
+
+test('warehouse exchange never presents a cancelled undelivered balance as another physical issue', function () {
+ $delivery = exchangeDeliver($this);
+ exchangeCommand($this, $this->reviewer, 'cancel');
+ $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.event_id', $delivery->id);
+ exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
+ $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(2, 'items')->assertJsonPath('items.1.action', 'receive');
+});
+
+test('warehouse exchange refuses another context and invalidates cursors after context activation', function () {
+ exchangeDeliver($this);
+ $actor = $this->f['issuer'];
+ $this->actingAs($actor);
+ $legacyCursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
+ $this->f['tenant']->update(['rules_provisional' => false, 'work_context_policy' => ['revision' => 1, 'enabled' => ['procurement.internal', 'people.internal']]]);
+ config(['work_context.entitlements.'.$actor->tenant_id => ['procurement.internal', 'people.internal']]);
+ supplyGrant($actor, ['admin']);
+ DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->update(['work_contexts' => json_encode(['procurement.internal', 'people.internal'])]);
+ $this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
+ $this->getJson('/stock/integracao/entregas')->assertForbidden();
+ $this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
+ $this->getJson('/stock/integracao/entregas?cursor='.urlencode($legacyCursor))->assertUnprocessable();
+ $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')
+ ->assertJsonPath('items.0.request.work_context_key', 'procurement.internal');
+});
+
+test('warehouse exchange preserves the equipment selected at the request origin', function () {
+ $level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
+ 'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério sintético.', 'is_critical' => false, 'created_at' => now()]);
+ $equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
+ 'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id,
+ 'code' => 'EQ-1', 'description' => 'Máquina da oficina', 'area' => 'Oficina', 'status' => 'active']);
+ $this->actingAs($this->f['issuer']);
+ $this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(),
+ 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
+ 'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal',
+ 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'equipment', 'equipment_id' => $equipment->id]]]);
+ exchangeDeliver($this, '4.000');
+ $equipment->update(['description' => 'Descrição futura']);
+ $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonPath('items.0.lines.0.equipment.id', $equipment->id)
+ ->assertJsonPath('items.0.lines.0.equipment.description', 'Máquina da oficina')->assertJsonPath('items.0.lines.0.use', 'equipment');
+});
+
+test('warehouse delivery feed is accepted by an independent durable receiver without another stock movement', function () {
+ $delivery = exchangeDeliver($this);
+ exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
+ $before = DB::table('stock_movements')->count();
+ $db = inboxDatabase();
+ $inbox = new WarehouseInbox($db, 'synthetic-source', $this->f['tenant']->id, 99, 'synthetic-consumer');
+ $checkpoint = $inbox->checkpoint();
+ $first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->json();
+ $accepted = $inbox->accept($first, $checkpoint['version'], $checkpoint['cursor']);
+ expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeTrue();
+ $checkpoint = $inbox->checkpoint();
+ $second = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($checkpoint['cursor']))->assertOk()->json();
+ $accepted = $inbox->accept($second, $checkpoint['version'], $checkpoint['cursor']);
+ expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeFalse()
+ ->and($inbox->accept($second, $checkpoint['version'], $checkpoint['cursor'])['replayed'])->toBeTrue()
+ ->and($inbox->fact($first['items'][0]['idempotency_key']))->toBe($first['items'][0])
+ ->and(DB::table('stock_movements')->count())->toBe($before);
+});
+
+test('warehouse feed cursor stays bounded even for a profile containing the whole permission catalogue', function () {
+ supplyGrant($this->f['issuer'], array_keys(OrganizationAccess::PERMISSIONS));
+ $this->actingAs($this->f['issuer']);
+ $cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
+ expect(strlen($cursor))->toBeLessThanOrEqual(4096);
+ $this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertOk()->assertJsonCount(0, 'items');
+});
diff --git a/tests/Feature/WarehouseFulfilmentTest.php b/tests/Feature/WarehouseFulfilmentTest.php
index 126363c..780b47f 100644
--- a/tests/Feature/WarehouseFulfilmentTest.php
+++ b/tests/Feature/WarehouseFulfilmentTest.php
@@ -1,15 +1,23 @@
reviewer, 'authorize', ['partial_allowed' => true]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000]);
$this->actingAs($this->f['receiver']);
- $issue=['kind'=>'issue','reference'=>'UNRELATED','idempotency_key'=>(string)Str::uuid(),'responsible_id'=>$this->f['issuer']->id,'warehouse_id'=>$this->f['warehouse']->id,'stock_item_id'=>$this->f['stock']->id,'quantity'=>'7.000','destination'=>'Outra oficina','reason'=>'Tentativa de saída que consumiria reserva.'];
- expect(fn()=>app(StockWorkflow::class)->record($this->f['receiver'],$issue))->toThrow(\Illuminate\Validation\ValidationException::class);
- expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(10000);
- warehouseCommand($this,$this->f['receiver'],'deliver',['lines'=>[['index'=>0,'quantity'=>'1.500']]]);
+ $issue = ['kind' => 'issue', 'reference' => 'UNRELATED', 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['issuer']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'quantity' => '7.000', 'destination' => 'Outra oficina', 'reason' => 'Tentativa de saída que consumiria reserva.'];
+ expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $issue))->toThrow(ValidationException::class);
+ expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
+ warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([2500]);
- $cancel=warehouseCommand($this,$this->reviewer,'cancel');
- $this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',$cancel)->assertRedirect();
- $p=app(WarehouseFulfilment::class)->progress($this->r);
+ $cancel = warehouseCommand($this, $this->reviewer, 'cancel');
+ $this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $cancel)->assertRedirect();
+ $p = app(WarehouseFulfilment::class)->progress($this->r);
expect($p['state'])->toBe('cancelled')->and($p['reserved'])->toBe([0])->and($p['cancelled'])->toBe([2500])->and($p['remaining'])->toBe([0]);
$this->actingAs($this->f['receiver']);
- app(StockWorkflow::class)->record($this->f['receiver'],$issue);
- expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(1500);
+ app(StockWorkflow::class)->record($this->f['receiver'], $issue);
+ expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(1500);
});
test('competing integral authorizations do not overbook and partial reservations can be replenished explicitly', function () {
- warehouseCommand($this,$this->f['issuer'],'submit');
- warehouseCommand($this,$this->reviewer,'authorize');
- $original=$this->r;
+ warehouseCommand($this, $this->f['issuer'], 'submit');
+ warehouseCommand($this, $this->reviewer, 'authorize');
+ $original = $this->r;
$this->actingAs($this->f['issuer']);
- $data=$this->draftData;$data['operation_key']=(string)Str::uuid();$data['lines'][0]['quantity']='8.000';
- $this->r=app(WarehouseRequests::class)->draft($this->f['issuer'],$data);
- warehouseCommand($this,$this->f['issuer'],'submit',['partial_allowed'=>true]);
- $this->actingAs($this->reviewer)->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',['operation_key'=>(string)Str::uuid(),'version'=>1,'action'=>'authorize','reason'=>'Tentativa de reservar mais que o stock livre.'])->assertUnprocessable();
+ $data = $this->draftData;
+ $data['operation_key'] = (string) Str::uuid();
+ $data['lines'][0]['quantity'] = '8.000';
+ $this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
+ warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
+ $this->actingAs($this->reviewer)->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Tentativa de reservar mais que o stock livre.'])->assertUnprocessable();
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('submitted');
- warehouseCommand($this,$this->reviewer,'authorize',['partial_allowed'=>true]);
+ warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([6000]);
- $second=$this->r;$this->r=$original;
- warehouseCommand($this,$this->reviewer,'cancel');
- $this->r=$second;
- warehouseCommand($this,$this->reviewer,'reserve');
+ $second = $this->r;
+ $this->r = $original;
+ warehouseCommand($this, $this->reviewer, 'cancel');
+ $this->r = $second;
+ warehouseCommand($this, $this->reviewer, 'reserve');
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([8000]);
- expect(fn()=>DB::table('warehouse_reservations')->where('warehouse_request_id',$second->id)->update(['quantity_milli'=>0]))->toThrow(\Illuminate\Database\QueryException::class);
+ expect(fn () => DB::table('warehouse_reservations')->where('warehouse_request_id', $second->id)->update(['quantity_milli' => 0]))->toThrow(QueryException::class);
});
test('warehouse authorization rejects self decision stale version and unauthorized physical delivery', function () {
@@ -112,6 +123,28 @@ test('warehouse authorization rejects self decision stale version and unauthoriz
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
});
+test('quality hold can block already reserved material without releasing the commitment or permitting physical delivery', function () {
+ supplyGrant($this->f['issuer'], ['inventory_lot_prepare']);
+ supplyGrant($this->f['receiver'], ['inventory_lot_review', 'inventory_lot_release', 'inventory_lot_hold']);
+ $this->actingAs($this->f['issuer']);
+ $setup = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'Q-RES', 'expires_on' => today()->addMonth()->toDateString()]]], 'prepare'));
+ $this->actingAs($this->f['receiver']);
+ app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $setup));
+ $lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
+ $metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']);
+ app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', $metadata));
+ warehouseCommand($this, $this->f['issuer'], 'submit');
+ warehouseCommand($this, $this->reviewer, 'authorize');
+ $this->actingAs($this->f['receiver']);
+ $lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
+ app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'hold', $metadata));
+ $this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Tentativa de entrega de lote retido.', 'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertUnprocessable();
+ expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000])->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000)
+ ->and(app(WarehouseReservations::class)->free($this->f['stock']->id, $this->f['warehouse']->id))->toBe(-4000);
+ $this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('health.needs_check', true)->where('health.delivery_ready', false));
+ $this->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.needs_check', true)->where('records.data.0.health.delivery_ready', false));
+});
+
test('shortage on a later line rolls back the entire delivery and preserves the authorization', function () {
$other = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'ZERO', 'name' => 'Artigo sem saldo', 'unit' => 'cx']);
$data = $this->draftData;
@@ -134,6 +167,102 @@ test('rejection prevents delivery without changing stock', function () {
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
});
+test('workdesk state projection matches the authoritative journal through partial deliveries receipts cancellation and rejection', function () {
+ $check = function (string $state) {
+ $derived = WarehouseRequest::query()->whereKey($this->r->id)->selectRaw(app(WarehouseWorkdesk::class)->stateExpression().' as derived_state')->firstOrFail()->getAttribute('derived_state');
+ expect($derived)->toBe($state)->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe($state);
+ };
+ $check('draft');
+ warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
+ $check('submitted');
+ warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
+ $check('authorized');
+ warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
+ $check('partial');
+ $first = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->sole();
+ warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $first->id]);
+ $check('partial');
+ warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '3.000']]]);
+ $check('delivered');
+ $last = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->latest('id')->firstOrFail();
+ warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
+ $check('closed');
+ foreach (['reject' => 'rejected', 'cancel' => 'cancelled'] as $action => $state) {
+ $this->actingAs($this->f['issuer']);
+ $data = $this->draftData;
+ $data['operation_key'] = (string) Str::uuid();
+ $this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
+ warehouseCommand($this, $this->f['issuer'], 'submit');
+ if ($action === 'cancel') {
+ warehouseCommand($this, $this->reviewer, 'authorize');
+ }
+ warehouseCommand($this, $this->reviewer, $action);
+ $check($state);
+ }
+});
+
+test('workdesk tasks follow existing permissions and segregation without turning consultation into a grant', function () {
+ warehouseCommand($this, $this->f['issuer'], 'submit');
+ $before = DB::table('warehouse_request_events')->count();
+ $this->actingAs($this->reviewer)->get('/stock/requisicoes?view=review')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.actions.canReview', true)->where('states.submitted', 1));
+ $this->actingAs($this->f['issuer'])->get('/stock/requisicoes?view=review')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
+ expect(DB::table('warehouse_request_events')->count())->toBe($before);
+ warehouseCommand($this, $this->reviewer, 'authorize');
+ supplyGrant($this->reviewer, ['warehouse', 'warehouse_request_deliver']);
+ $this->actingAs($this->reviewer)->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
+ $this->actingAs($this->f['receiver'])->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.delivery_ready', true));
+ $this->actingAs($this->reviewer)->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('canDeliver', false)->has('people.delivery', 1)->where('people.delivery.0.id', $this->f['receiver']->id));
+ DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
+ $this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->has('people.delivery', 0));
+});
+
+test('workdesk retains unconfirmed deliveries after balance cancellation and removes the task only after receipt', function () {
+ warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
+ warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
+ warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
+ warehouseCommand($this, $this->reviewer, 'cancel');
+ $this->actingAs($this->f['issuer'])->get('/stock/requisicoes?view=receipt&state=cancelled')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.unconfirmed_deliveries', 1));
+ $delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->sole();
+ warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
+ $this->get('/stock/requisicoes?view=receipt')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
+ expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('cancelled');
+});
+
+test('workdesk filtering precedes pagination and finds old requests without adding new movements', function () {
+ $first = $this->r;
+ $this->actingAs($this->f['issuer']);
+ for ($i = 0; $i < 17; $i++) {
+ $data = $this->draftData;
+ $data['operation_key'] = (string) Str::uuid();
+ app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
+ }
+ warehouseCommand($this, $this->f['issuer'], 'submit');
+ $moves = DB::table('stock_movements')->count();
+ $this->actingAs($this->reviewer)->get('/stock/requisicoes?view=review&state=submitted&search=Oficina')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $first->id));
+ $this->get('/stock/requisicoes?view=all&page=2')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 18)->has('records.data', 3));
+ $this->get('/stock/requisicoes?search=RA-'.$first->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $first->id));
+ expect(DB::table('stock_movements')->count())->toBe($moves);
+});
+
+test('workdesk counts do not cross company department or warehouse scope', function () {
+ $foreign = receiptFixture();
+ supplyGrant($foreign['issuer'], ['warehouse_request_prepare']);
+ $this->actingAs($foreign['issuer']);
+ $data = $this->draftData;
+ $data['operation_key'] = (string) Str::uuid();
+ $data['warehouse_id'] = $foreign['warehouse']->id;
+ $data['cost_center_id'] = $foreign['r']->cost_center_id;
+ $data['lines'][0]['stock_item_id'] = $foreign['stock']->id;
+ app(WarehouseRequests::class)->draft($foreign['issuer'], $data);
+ $this->actingAs($this->reviewer)->get('/stock/requisicoes')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('views.0.total', 1)->where('records.data.0.id', $this->r->id));
+ $this->getJson('/stock/requisicoes?warehouse_id='.$foreign['warehouse']->id)->assertUnprocessable();
+ $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra unidade do ensaio']);
+ $outsider = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
+ supplyGrant($outsider, ['warehouse_request_review']);
+ $this->actingAs($outsider)->get('/stock/requisicoes')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0)->where('views.0.total', 0));
+ $this->get('/stock/requisicoes/'.$this->r->id)->assertForbidden();
+});
+
test('changed source and excess quantities are refused on the live document', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
diff --git a/tests/Feature/WarehousePilotJourneyTest.php b/tests/Feature/WarehousePilotJourneyTest.php
new file mode 100644
index 0000000..e9fe71c
--- /dev/null
+++ b/tests/Feature/WarehousePilotJourneyTest.php
@@ -0,0 +1,68 @@
+actingAs($f['receiver']);
+ app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '120.000'));
+ app(RequisitionCatalogue::class)->save($f['receiver'], $f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($f['stock']), 'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Publicação do artigo para o ensaio sintético integrado.']);
+ $this->actingAs($f['issuer']);
+ $policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'purpose' => 'internal', 'origin' => 'local']));
+ $this->actingAs($f['receiver']);
+ $policy = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
+ $this->actingAs($f['issuer']);
+ $rule = app(AutomaticPurchases::class)->save($f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Estimativa de dez MZN por unidade apenas para o ensaio descartável.', 'data' => ['policy_key' => $policy->record_key, 'requester_id' => $f['issuer']->id, 'cost_center_id' => $f['r']->cost_center_id, 'unit_price' => '10.00', 'tax_rate' => '0', 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
+ $this->actingAs($f['receiver']);
+ app(AutomaticPurchases::class)->save($f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Conferência independente da parametrização sintética.', 'data' => []]);
+ $this->actingAs($f['issuer']);
+ $request = app(WarehouseRequests::class)->draft($f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $f['warehouse']->id, 'cost_center_id' => $f['r']->cost_center_id, 'destination' => 'Oficina sintética do piloto', 'reason' => 'Necessidade interna de material para manutenção no ensaio.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $f['stock']->id, 'quantity' => '125.000', 'use' => 'internal']]]);
+ $command = function ($actor, string $action, array $extra = []) use ($request) {
+ $this->actingAs($actor);
+ $version = app(WarehouseFulfilment::class)->progress($request)['version'];
+ $this->postJson('/stock/requisicoes/'.$request->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => $version, 'action' => $action, 'reason' => 'Intervenção efectiva no percurso sintético do piloto.', ...$extra])->assertRedirect();
+ };
+ $command($f['issuer'], 'submit', ['partial_allowed' => true]);
+ $command($f['receiver'], 'authorize', ['partial_allowed' => true]);
+ app(AutomaticPurchases::class)->evaluate($f['receiver'], $f['stock']->id, $f['warehouse']->id);
+ $need = app(SupplyPlanning::class)->latest('auto_need')->sole();
+ expect($need->data['purchases'])->toHaveCount(1);
+ $purchase = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
+ expect($purchase->status)->toBe('draft')->and($purchase->items()->sole()->quantity)->toBe('108.000')->and($purchase->steps()->count())->toBe(0);
+ $this->actingAs($f['otherReceiver'])->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.needs_reservation', true)->where('records.data.0.health.delivery_ready', true));
+ $command($f['otherReceiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '100.000']]]);
+ $delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'deliver')->sole();
+ $command($f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
+ expect(app(WarehouseFulfilment::class)->progress($request)['reserved'])->toBe([20000]);
+ $this->actingAs($f['otherReceiver']);
+ // Independent stock can arrive from an already issued order. It does not approve the automatic draft.
+ app(ReceiptWorkflow::class)->confirm($f['otherReceiver'], $f['order'], receiptPayload($f, '108.000'));
+ $command($f['receiver'], 'reserve');
+ expect(app(WarehouseFulfilment::class)->progress($request)['reserved'])->toBe([25000]);
+ $command($f['otherReceiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '25.000']]]);
+ $last = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'deliver')->latest('id')->firstOrFail();
+ $command($f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
+ $p = app(WarehouseFulfilment::class)->progress($request);
+ expect($p['state'])->toBe('closed')->and($p['reserved'])->toBe([0])->and($p['delivered'])->toBe([125000])
+ ->and(app(StockWorkflow::class)->balance($f['stock']->id, $f['warehouse']->id)['quantity'])->toBe(103000)
+ ->and($purchase->fresh()->status)->toBe('draft')->and(DB::table('invoices')->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0)
+ ->and(Requisition::query()->where('status', 'draft')->count())->toBe(1);
+ $this->get('/stock/requisicoes?view=receipt')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total',0));
+});
diff --git a/tests/Feature/WarehousePilotReadinessTest.php b/tests/Feature/WarehousePilotReadinessTest.php
new file mode 100644
index 0000000..bd8ef2f
--- /dev/null
+++ b/tests/Feature/WarehousePilotReadinessTest.php
@@ -0,0 +1,145 @@
+f = suggestionFixture();
+ app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
+ $this->actingAs($this->f['issuer']);
+ $policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]));
+ $this->actingAs($this->f['receiver']);
+ $this->f['policy'] = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
+ supplyGrant($this->f['otherReceiver'], ['admin']);
+});
+
+function pilotReadinessRule($test): void
+{
+ $test->actingAs($test->f['issuer']);
+ $rule = app(AutomaticPurchases::class)->save($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização sintética do diagnóstico de piloto.', 'data' => ['policy_key' => $test->f['policy']->record_key, 'requester_id' => $test->f['issuer']->id, 'cost_center_id' => $test->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
+ $test->actingAs($test->f['receiver']);
+ app(AutomaticPurchases::class)->save($test->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Activação independente da regra sintética conferida.', 'data' => []]);
+}
+
+test('pilot configuration export is private read only and does not include the account register', function () {
+ $counts = [];
+ foreach (['users', 'organization_memberships', 'organization_profiles', 'audit_logs', 'stock_movements', 'supply_records', 'requisitions'] as $table) {
+ $counts[$table] = DB::table($table)->count();
+ }
+ $this->actingAs($this->f['receiver'])->get('/stock/piloto.csv')->assertForbidden();
+ $response = $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto.csv')->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
+ $csv = $response->streamedContent();
+ expect($csv)->toContain('Conferido em')->toContain('A preparar')->toContain('SGC autónomo')->not->toContain($this->f['issuer']->name);
+ foreach ($counts as $table => $count) {
+ expect(DB::table($table)->count())->toBe($count);
+ }
+});
+
+test('pilot export renders formula-like text as text in spreadsheet readers', function () {
+ $this->mock(WarehousePilotReadiness::class)->shouldReceive('inspect')->once()->andReturn(['checked_at' => now()->toIso8601String(), 'checks' => [['label' => '=1+1', 'ok' => false, 'detail' => "\t@SUM(1)", 'path' => null]]]);
+ $response = $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto.csv')->assertOk();
+ $csv = $response->streamedContent();
+ expect($csv)->toContain("'=1+1")->toContain("'\t@SUM(1)");
+});
+
+test('pilot diagnostics are read only and distinguish a viable synthetic warehouse scenario from client delivery and an unobserved scheduler', function () {
+ supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
+ supplyGrant($this->f['receiver'], ['warehouse_request_review']);
+ supplyGrant($this->f['otherReceiver'], ['warehouse', 'warehouse_request_deliver']);
+ pilotReadinessRule($this);
+ $counts = [];
+ foreach (['requisitions', 'warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'audit_logs', 'supply_records', 'replenishment_work', 'organization_memberships', 'organization_profiles'] as $table) {
+ $counts[$table] = DB::table($table)->count();
+ }
+ $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p->component('stock/pilot')->where('syntheticEvaluation', true)->where('report.warehouse_scenario_ready', true)->where('report.replenishment_scenario_ready', false)->where('report.client_configuration_complete', false)->where('report.checks.3.ok', true)->where('report.checks.4.ok', false)->has('report.participants.prepare', 1)->has('report.participants.deliver', 1));
+ foreach ($counts as $table => $count) {
+ expect(DB::table($table)->count())->toBe($count);
+ }
+});
+
+test('having all task labels among two people does not satisfy three-way independence', function () {
+ supplyGrant($this->f['issuer'], ['warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver', 'warehouse']);
+ supplyGrant($this->f['receiver'], ['warehouse_request_review']);
+ $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->where('report.warehouse_scenario_ready', false)->where('report.checks.1.ok', false)->has('report.participants.prepare', 1)->has('report.participants.review', 2)->has('report.participants.deliver', 1));
+});
+
+test('rule inspection reuses generation authority and source validity without creating drafts', function () {
+ pilotReadinessRule($this);
+ $before = Requisition::query()->count();
+ DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
+ $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->where('report.checks.3.ok', false));
+ expect(Requisition::query()->count())->toBe($before);
+});
+
+test('pilot diagnostics do not admit operational readers or expose accounts from another company', function () {
+ $this->actingAs($this->f['receiver'])->get('/stock/piloto')->assertForbidden();
+ $foreign = receiptFixture();
+ supplyGrant($foreign['issuer'], ['admin']);
+ $this->actingAs($foreign['issuer'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->has('report.registered', 3)->where('report.warehouse_scenario_ready', false)->where('report.checks.2.ok', false)->where('report.registered', fn ($people) => collect($people)->pluck('id')->sort()->values()->all() === collect([$foreign['issuer']->id, $foreign['receiver']->id, $foreign['otherReceiver']->id])->sort()->values()->all()));
+});
+
+test('stale catalogue and expired automatic configuration are reported instead of being treated as ready', function () {
+ pilotReadinessRule($this);
+ $this->f['stock']->update(['name' => 'Cadastro actualizado depois da publicação']);
+ $this->travel(32)->days();
+ $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->where('report.checks.2.ok', false)->where('report.checks.3.ok', false)->where('report.replenishment_scenario_ready', false));
+});
+
+test('optional scheduler telemetry is isolated and a stale or damaged file does not imply a running service', function () {
+ $originalStorage = $this->app->storagePath();
+ $originalEnv = $this->app->environment();
+ $temporary = $originalStorage.'/framework/testing/pilot-status-'.Str::uuid();
+ mkdir($temporary.'/framework', 0755, true);
+ $this->app->useStoragePath($temporary);
+ $this->app->instance('env', 'payable-testing');
+ $path = $temporary.'/framework/replenishment-status.json';
+ $this->actingAs($this->f['otherReceiver']);
+ try {
+ file_put_contents($path, '{bad-json');
+ expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerStatus'])->toBeNull();
+ file_put_contents($path, json_encode(['result' => 'passed', 'finished_at' => now()->subHour()->toIso8601String()], JSON_THROW_ON_ERROR));
+ expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerRecent'])->toBeFalse();
+ file_put_contents($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR));
+ expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerRecent'])->toBeTrue();
+ } finally {
+ $this->app->useStoragePath($originalStorage);
+ $this->app->instance('env', $originalEnv);
+ unlink($path);
+ rmdir($temporary.'/framework');
+ rmdir($temporary);
+ }
+});
+
+test('a recent successful scheduler does not hide durable work still awaiting recovery', function () {
+ pilotReadinessRule($this);
+ $this->actingAs($this->f['otherReceiver']);
+ DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->update(['processed_generation' => DB::raw('generation'), 'failures' => 0]);
+ $originalStorage = $this->app->storagePath();
+ $originalEnv = $this->app->environment();
+ $temporary = $originalStorage.'/framework/testing/pilot-recovery-'.Str::uuid();
+ mkdir($temporary.'/framework', 0755, true);
+ $this->app->useStoragePath($temporary);
+ $this->app->instance('env', 'payable-testing');
+ $path = $temporary.'/framework/replenishment-status.json';
+ try {
+ file_put_contents($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR));
+ expect(app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver'])['replenishment_scenario_ready'])->toBeTrue();
+ DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->update(['failures' => 1, 'due_at' => now()->addHour()]);
+ $report = app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver']);
+ expect($report['runtime']['schedulerRecent'])->toBeTrue()->and($report['replenishment_scenario_ready'])->toBeFalse()->and($report['checks'][6]['ok'])->toBeFalse();
+ } finally {
+ $this->app->useStoragePath($originalStorage);
+ $this->app->instance('env', $originalEnv);
+ unlink($path);
+ rmdir($temporary.'/framework');
+ rmdir($temporary);
+ }
+});
diff --git a/tests/Feature/WarehouseReturnIntegrationTest.php b/tests/Feature/WarehouseReturnIntegrationTest.php
new file mode 100644
index 0000000..eb18508
--- /dev/null
+++ b/tests/Feature/WarehouseReturnIntegrationTest.php
@@ -0,0 +1,176 @@
+assertMatchesRegularExpression('/^compras_receipts_test_[a-f0-9]{12}$/D',$i->db);$this->assertSame('stock_receipt_qa_'.substr($i->db,-12),$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster);
+ if(!DB::getSchemaBuilder()->hasTable('users'))Artisan::call('migrate',['--force'=>true]);config(['stock_returns.enabled'=>true]);
+ $this->f=\receiptFixture('10.000');$this->reviewer=User::factory()->create(['tenant_id'=>$this->f['tenant']->id,'department_id'=>$this->f['issuer']->department_id]);
+ \supplyGrant($this->f['issuer'],['warehouse_request_prepare','warehouse_return_prepare','warehouse_return_read','warehouse_return_confirm','warehouse']);
+ \supplyGrant($this->reviewer,['warehouse_request_review']);\supplyGrant($this->f['receiver'],['warehouse','warehouse_request_deliver','warehouse_return_read','warehouse_return_confirm']);\supplyGrant($this->f['otherReceiver'],['warehouse','warehouse_return_read']);
+ $this->actingAs($this->f['receiver']);app(ReceiptWorkflow::class)->confirm($this->f['receiver'],$this->f['order'],\receiptPayload($this->f,'10.000'));
+ $this->actingAs($this->f['issuer']);$r=app(WarehouseRequests::class)->draft($this->f['issuer'],['operation_key'=>(string)Str::uuid(),'warehouse_id'=>$this->f['warehouse']->id,'cost_center_id'=>$this->f['r']->cost_center_id,'destination'=>'Synthetic internal workshop','reason'=>'Synthetic authorised internal material use.','priority'=>'normal','lines'=>[['stock_item_id'=>$this->f['stock']->id,'quantity'=>'5.000','use'=>'internal','equipment_id'=>null]]]);$this->requestId=$r->id;
+ $this->command($this->f['issuer'],'submit',['partial_allowed'=>false]);$this->command($this->reviewer,'authorize',['partial_allowed'=>false]);$this->command($this->f['receiver'],'deliver',['lines'=>[['index'=>0,'quantity'=>'5.000']]]);
+ $this->delivery=(int)WarehouseRequestEvent::query()->where('warehouse_request_id',$r->id)->where('action','deliver')->value('id');$this->command($this->f['issuer'],'receive',['delivery_event_id'=>$this->delivery]);
+ $this->payload=['operation_key'=>(string)Str::uuid(),'delivery_event_id'=>$this->delivery,'line_index'=>0,'quantity'=>'3.000','declared_unused'=>true,'evidence'=>'Synthetic unused quantity and physical custody evidence.','reason'=>'Synthetic unused material returned to its original warehouse.'];$this->actingAs($this->f['issuer']);
+ }
+ private function command(User $actor,string $action,array $extra):void
+ {
+ $this->actingAs($actor);$r=\App\Models\WarehouseRequest::query()->findOrFail($this->requestId);$d=['operation_key'=>(string)Str::uuid(),'version'=>app(WarehouseFulfilment::class)->progress($r)['version'],'action'=>$action,'reason'=>'Synthetic explicit request intervention.']+$extra;
+ $this->postJson('/stock/requisicoes/'.$r->id.'/eventos',$d)->assertRedirect();
+ }
+ private function prepare(?array $payload=null){return $this->postJson('/api/stock-returns/prepare',$payload??$this->payload);}
+ private function confirm(int $id,int $version=1){return $this->postJson('/api/stock-returns/'.$id.'/confirm',['version'=>$version,'received_in_warehouse'=>true,'reason'=>'Synthetic independent physical warehouse receipt.']);}
+ private function closeProposal(int $id,string $action,int $version=1){return $this->postJson('/api/stock-returns/'.$id.'/'.$action,['version'=>$version,'reason'=>'Synthetic reason for closing without physical movement.']);}
+ private function balance():int{return (int)StockMovement::query()->sum('quantity_milli');}
+ public function test_native_receipt_issue_acknowledgement_and_return_same_book_once():void
+ {
+ $this->assertSame(5000,$this->balance());$p=$this->prepare()->assertOk()->assertJsonPath('impact.physical_before','5000')->assertJsonPath('impact.physical_after','8000')->json('id');$this->prepare()->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->confirm($p)->assertForbidden();
+ $prepared=SupplyRecord::query()->findOrFail($p);$this->assertSame($this->f['issuer']->id,$prepared->snapshot['source']['acknowledgement']['actor_id']);
+ $this->actingAs($this->f['receiver']);$this->postJson('/api/stock-returns/'.$p.'/confirm',['version'=>1,'reason'=>'Synthetic receipt without acknowledgement'])->assertStatus(422);$m=$this->confirm($p)->assertOk()->json('movement_id');$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$m)->assertJsonPath('replayed',true);
+ $this->assertSame(8000,$this->balance());$this->assertSame(3,StockMovement::query()->count());$this->assertSame(2,StockOperation::query()->count());$this->assertSame(2,SupplyRecord::query()->where('kind','warehouse_return')->count());
+ $history=$this->get('/stock/operacoes')->assertOk()->assertInertia(fn($page)=>$page->component('stock/operations')->where('canViewReturns',true)->where('operations.data.0.kind','workshop_return')->where('operations.data.0.snapshot.stock_item.id',$this->f['stock']->id)->where('operations.data.0.snapshot.warehouse.id',$this->f['warehouse']->id)->where('operations.data.0.snapshot.actor.id',$this->f['receiver']->id)->where('operations.data.0.snapshot.responsible.id',$this->f['receiver']->id));
+ $historyRoot=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($historyRoot);file_put_contents($historyRoot.'/native-stock-history-page.json',json_encode($history->viewData('page'),JSON_THROW_ON_ERROR));
+ $this->getJson('/api/stock-returns?state=confirmed')->assertOk()->assertJsonPath('pagination.total',1)->assertJsonPath('rows.0.version',2);$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',0);
+ $this->assertTrue(DB::table('audit_logs')->where('tenant_id',$this->f['tenant']->id)->where('action','warehouse_return.confirmed')->exists());
+ $root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);file_put_contents($root.'/return-results.json',json_encode(['kind'=>'native_receipt_internal_issue_and_return','tenant'=>$this->f['tenant']->id,'warehouse'=>$this->f['warehouse']->id,'item'=>$this->f['stock']->id,'return_movement'=>$m,'request_id'=>$this->requestId,'physical_milli'=>'8000','native_movements'=>3,'shared_return_manifest_sha256'=>hash_file('sha256',base_path('packages/inventory-return/releases/0.1.0/manifest.json'))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ }
+ public function test_stale_capacity_idempotency_versions_rights_and_context_refuse_effect():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$changed=$this->payload;$changed['quantity']='2.000';$this->prepare($changed)->assertConflict();$second=$this->payload;$second['operation_key']=(string)Str::uuid();$other=$this->prepare($second)->assertOk()->json('id');
+ $this->actingAs($this->f['otherReceiver']);$this->confirm($p)->assertForbidden();$this->actingAs($this->f['receiver']);$this->confirm($p,2)->assertStatus(422);$this->confirm($p)->assertOk();$this->confirm($other)->assertConflict();$this->assertSame(8000,$this->balance());
+ $this->actingAs($this->f['issuer']);$second['quantity']='2.001';$second['operation_key']=(string)Str::uuid();$this->prepare($second)->assertConflict();$second['quantity']='2.000';$second['operation_key']=(string)Str::uuid();$p=$this->prepare($second)->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->assertSame(10000,$this->balance());
+ config(['stock_returns.enabled'=>false]);$this->getJson('/api/stock-returns')->assertStatus(503);$this->assertSame(10000,$this->balance());
+ }
+ public function test_decision_failure_rolls_back_physical_movement_and_retry_recovers():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*supply_records/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic native return decision failure');}});
+ $this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Missing fault');}catch(\RuntimeException $e){$this->assertSame('Synthetic native return decision failure',$e->getMessage());}$this->withExceptionHandling();
+ $this->assertSame(5000,$this->balance());$this->assertSame(2,StockMovement::query()->count());$this->assertSame(1,StockOperation::query()->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
+ }
+ public function test_undeclared_fraction_and_foreign_delivery_are_refused():void
+ {
+ $d=$this->payload;$d['declared_unused']=false;$this->prepare($d)->assertStatus(422);$d=$this->payload;$d['quantity']='0.0001';$this->prepare($d)->assertStatus(422);
+ $foreign=User::factory()->create(['tenant_id'=>\App\Models\Tenant::query()->create(['name'=>'Synthetic other native company'])->id]);\supplyGrant($foreign,['warehouse_return_prepare','warehouse_return_confirm','warehouse_return_read','warehouse']);$this->actingAs($foreign);$this->prepare()->assertNotFound();$this->getJson('/api/stock-returns')->assertOk()->assertJsonPath('pagination.total',0);
+ }
+ public function test_source_options_and_page_reuse_request_and_acknowledged_line():void
+ {
+ $this->getJson('/api/stock-returns/sources?request_id='.$this->requestId)->assertOk()->assertJsonPath('options.0.delivery_event_id',$this->delivery)->assertJsonPath('options.0.item.code',$this->f['stock']->code)->assertJsonPath('can_prepare',true);
+ $this->get('/stock/devolucoes-internas?request_id='.$this->requestId)->assertOk()->assertHeader('Cache-Control','max-age=0, no-store, private')->assertInertia(fn($page)=>$page->component('stock/returns')->where('requestId',$this->requestId)->where('canPrepare',true));
+ $this->getJson('/api/stock-returns?state=wrong')->assertStatus(422);$this->getJson('/api/stock-returns?page=0')->assertStatus(422);$this->getJson('/api/stock-returns?tenant_id=999')->assertStatus(422);
+ $this->actingAs($this->f['receiver']);$this->getJson('/api/stock-returns/sources?request_id='.$this->requestId)->assertOk()->assertJsonPath('can_prepare',false);$this->assertSame(5000,$this->balance());
+ $this->actingAs($this->f['otherReceiver']);$this->get('/stock/operacoes')->assertOk()->assertInertia(fn($page)=>$page->where('canViewReturns',true));$this->get('/stock/devolucoes-internas')->assertOk()->assertInertia(fn($page)=>$page->component('stock/returns')->where('requestId',null)->where('canConfirm',false));$this->get('/stock/devolucoes-internas?request_id='.$this->requestId)->assertForbidden();
+ }
+ public function test_source_issue_with_return_cannot_be_reversed_as_a_whole():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$operation=(int)StockOperation::query()->where('kind','issue')->value('id');
+ try{app(\App\Services\InventoryReversals::class)->basis(['source_kind'=>'operation','source_operation_id'=>$operation]);$this->fail('Original issue with return must not be reversed');}catch(\Illuminate\Validation\ValidationException $e){$this->assertStringContainsString('devolução física',implode(' ',array_merge(...array_values($e->errors()))));}
+ $this->assertSame(8000,$this->balance());
+ }
+ public function test_concurrent_native_confirmation_waits_then_replays_one_movement():void
+ {
+ $this->nativeRace();
+ }
+ private function nativeRace(?int $given=null,?int $workerProposal=null,int $workerStatus=200,bool $parentWithdraw=false,?User $workerActor=null,string $workerAction='confirm'):void
+ {
+ $p=$given??$this->prepare()->assertOk()->json('id');$this->actingAs($this->f[$parentWithdraw?'issuer':'receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$dir=$root.'/return-race-'.bin2hex(random_bytes(6));mkdir($dir,0700,true);$input=$dir.'/worker.json';$application='native_return_'.bin2hex(random_bytes(6));$process=null;
+ file_put_contents($input,json_encode(['actor'=>($workerActor??$this->f['receiver'])->id,'proposal'=>$workerProposal??$p,'application'=>$application,'action'=>$workerAction],JSON_THROW_ON_ERROR));DB::beginTransaction();
+ try{$winner=$parentWithdraw?$this->closeProposal($p,'withdraw')->assertOk()->json('id'):$this->confirm($p)->assertOk()->json('movement_id');$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/warehouse-return-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
+ do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit();
+ $deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);$result=json_decode(file_get_contents($input.'.result'),true);$this->assertSame($workerStatus,$result['status']);if($workerStatus===200){$this->assertTrue($result['result']['replayed']);$this->assertSame($winner,$result['result']['movement_id']);}
+ }finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
+ $this->assertSame($parentWithdraw?5000:8000,$this->balance());$this->assertSame($parentWithdraw?2:3,StockMovement::query()->count());$this->assertSame(1,SupplyRecord::query()->where('kind','warehouse_return')->where('state',$parentWithdraw?'withdrawn':'confirmed')->count());
+ }
+ public function test_competing_native_returns_cannot_exceed_the_original_delivery():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$d=$this->payload;$d['operation_key']=(string)Str::uuid();$other=$this->prepare($d)->assertOk()->json('id');$this->nativeRace($p,$other,409);
+ }
+ public function test_expired_and_maintenance_profiles_are_revalidated_at_confirmation():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['maintenance'=>true]);$this->confirm($p)->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['maintenance'=>false,'expires_at'=>now()->subMinute()]);$this->confirm($p)->assertForbidden();$this->assertSame(5000,$this->balance());DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['expires_at'=>null]);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
+ }
+ public function test_configured_context_and_permissions_cannot_be_inherited_from_people_profile():void
+ {
+ $tenant=$this->f['tenant']->id;$this->f['tenant']->update(['rules_provisional'=>false,'work_context_policy'=>['revision'=>1,'enabled'=>['procurement.internal','people.internal']]]);config(['work_context.entitlements.'.$tenant=>['procurement.internal','people.internal']]);DB::table('organization_profiles')->where('tenant_id',$tenant)->update(['work_contexts'=>json_encode(['procurement.internal'])]);
+ $this->prepare()->assertForbidden();$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertForbidden();$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');
+ $profiles=DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->pluck('profile_id');DB::table('organization_profiles')->whereIn('id',$profiles)->update(['permissions'=>json_encode(['warehouse','warehouse_request_deliver','warehouse_return_read'])]);\supplyGrant($this->f['receiver'],['warehouse_return_confirm']);$other=DB::table('organization_profiles')->where('tenant_id',$tenant)->whereNotIn('id',$profiles)->orderByDesc('id')->value('id');DB::table('organization_profiles')->where('id',$other)->update(['work_contexts'=>json_encode(['people.internal'])]);
+ $this->confirm($p)->assertForbidden();$this->assertSame(5000,$this->balance());DB::table('organization_profiles')->where('id',$other)->update(['work_contexts'=>json_encode(['procurement.internal'])]);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
+ }
+ public function test_return_signals_existing_replenishment_only_once_and_inside_transaction():void
+ {
+ $spy=new class extends \App\Services\AutomaticPurchases {public array $calls=[];public function signal(User $actor,int $item,int $warehouse):void {$this->calls[]=['actor'=>$actor->id,'item'=>$item,'warehouse'=>$warehouse,'transaction_level'=>DB::transactionLevel()];}};
+ $this->app->instance(\App\Services\AutomaticPurchases::class,$spy);$p=$this->prepare()->assertOk()->json('id');$this->assertCount(0,$spy->calls);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->confirm($p)->assertOk()->assertJsonPath('replayed',true);
+ $this->assertCount(1,$spy->calls);$this->assertSame($this->f['stock']->id,$spy->calls[0]['item']);$this->assertSame($this->f['warehouse']->id,$spy->calls[0]['warehouse']);$this->assertGreaterThan(0,$spy->calls[0]['transaction_level']);$this->assertSame(8000,$this->balance());
+ }
+ public function test_catalogue_rename_requires_new_review_preserving_original_and_unit_change_refuses():void
+ {
+ $name=$this->f['stock']->name;$p=$this->prepare()->assertOk()->json('id');$this->f['stock']->update(['name'=>'Synthetic renamed article']);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertConflict();$this->assertSame(5000,$this->balance());
+ $this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$p=$this->prepare()->assertOk()->assertJsonPath('impact.item.name','Synthetic renamed article')->json('id');$this->assertSame($name,SupplyRecord::query()->findOrFail($p)->snapshot['source']['original_item']['name']);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
+ $this->f['stock']->update(['unit'=>'KG']);$this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$this->payload['quantity']='1.000';$this->prepare()->assertConflict();$this->assertSame(8000,$this->balance());
+ }
+ public function test_unavailable_private_package_returns_503_and_rolls_back_before_recovery():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$manifest=realpath(base_path('packages/inventory-return/releases/0.1.0/manifest.json'));$this->assertStringStartsWith($root.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR,$manifest);$original=file_get_contents($manifest);$this->assertNotFalse($original);
+ try{file_put_contents($manifest,'{"synthetic_private_fault":true}');$this->confirm($p)->assertStatus(503);}finally{file_put_contents($manifest,$original);}
+ $this->assertSame(5000,$this->balance());$this->assertSame(1,StockOperation::query()->count());$this->assertSame(2,StockMovement::query()->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
+ }
+ public function test_http_busy_response_leaves_proposal_unconfirmed_then_manual_retry_succeeds():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$dir=$root.'/return-busy-'.bin2hex(random_bytes(6));mkdir($dir,0700,true);$input=$dir.'/worker.json';$appName='native_return_busy_'.bin2hex(random_bytes(6));$process=null;file_put_contents($input,json_encode(['actor'=>$this->f['receiver']->id,'proposal'=>$p,'application'=>$appName,'http'=>true],JSON_THROW_ON_ERROR));DB::beginTransaction();
+ try{DB::table('tenants')->where('id',$this->f['tenant']->id)->lockForUpdate()->first();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/warehouse-return-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
+ do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$appName]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));
+ $deadline=microtime(true)+12;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);$r=json_decode(file_get_contents($input.'.result'),true);$this->assertSame(423,$r['status']);$this->assertSame('warehouse_return_busy',$r['result']['code']);$this->assertArrayNotHasKey('exception',$r['result']);$this->assertSame(5000,$this->balance());DB::commit();
+ }finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
+ $this->assertSame(0,SupplyRecord::query()->where('kind','warehouse_return')->where('state','confirmed')->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
+ }
+ public function test_withdrawal_and_independent_rejection_close_pending_without_stock_effect():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->closeProposal($p,'reject')->assertForbidden();\supplyGrant($this->f['receiver'],['warehouse_return_prepare']);$this->actingAs($this->f['receiver']);$this->closeProposal($p,'withdraw')->assertForbidden();$this->actingAs($this->f['issuer']);$this->closeProposal($p,'withdraw',2)->assertConflict();$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('state','withdrawn');$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('replayed',true);$this->prepare()->assertOk()->assertJsonPath('id',$p)->assertJsonPath('state','withdrawn');
+ $this->actingAs($this->f['receiver']);$this->confirm($p)->assertConflict();$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',0);$this->getJson('/api/stock-returns?state=withdrawn')->assertOk()->assertJsonPath('pagination.total',1);
+ $this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('state','rejected');$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('replayed',true);$this->confirm($p)->assertConflict();$this->getJson('/api/stock-returns?state=rejected')->assertOk()->assertJsonPath('pagination.total',1);
+ $this->assertSame(5000,$this->balance());$this->assertSame(2,StockMovement::query()->count());$this->assertSame(1,StockOperation::query()->count());$this->assertSame(4,SupplyRecord::query()->where('kind','warehouse_return')->count());$this->assertSame(1,DB::table('audit_logs')->where('tenant_id',$this->f['tenant']->id)->where('action','warehouse_return.withdrawn')->count());
+ }
+ public function test_withdrawal_wins_before_confirmation_without_a_movement():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->nativeRace($p,$p,409,true);
+ }
+ public function test_confirmation_wins_before_withdrawal_without_duplicate_terminal_state():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$this->nativeRace($p,$p,409,false,$this->f['issuer'],'withdraw');
+ }
+ public function test_return_changes_physical_history_without_silently_changing_gross_issue_forecast():void
+ {
+ $history=app(\App\Services\SupplyHistory::class);$date=now()->toDateString();
+ $before=$history->analyse($this->f['stock']->id,$this->f['warehouse']->id,$date,$date);
+ $p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();
+ $after=$history->analyse($this->f['stock']->id,$this->f['warehouse']->id,$date,$date);
+ $this->assertSame(5000,$before['total_milli']);$this->assertSame(5000,$after['total_milli']);
+ $this->assertSame($before['daily_mean_milli'],$after['daily_mean_milli']);
+ $this->assertSame(5000,$before['series'][0]['balance_milli']);$this->assertSame(8000,$after['series'][0]['balance_milli']);
+ $this->assertSame(1,$after['movement_types']['workshop_return_in']);$this->assertNotSame($before['source_hash'],$after['source_hash']);
+ $this->assertSame($before['convention'],$after['convention']);$this->assertSame(3,$after['movement_count']);
+ $this->assertSame(8000,$this->balance());
+ file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/return-forecast-boundary.json',json_encode(['gross_issue_milli'=>5000,'returned_milli'=>3000,'physical_before_milli'=>5000,'physical_after_milli'=>8000,'historical_mean_unchanged'=>true,'source_hash_changed'=>true,'planning_convention_unchanged'=>true,'net_consumption_forecast_implemented'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ }
+ public function test_history_pages_and_batch_preparer_lookup_cover_the_full_pending_universe():void
+ {
+ $p=$this->prepare()->assertOk()->json('id');$row=SupplyRecord::query()->findOrFail($p)->getAttributes();unset($row['id']);for($i=0;$i<50;$i++){$copy=$row;$key=(string)Str::uuid();$copy['record_key']=$key;$copy['operation_key']=$key;$data=json_decode($copy['data'],true,512,JSON_THROW_ON_ERROR);$data['operation_key']=$key;$copy['data']=json_encode($data,JSON_THROW_ON_ERROR);$copy['input_hash']=hash('sha256',$copy['data']);DB::table('supply_records')->insert($copy);}
+ $queries=[];$capture=true;DB::listen(function($q)use(&$queries,&$capture){if($capture&&str_starts_with(strtolower($q->sql),'select')&&str_contains($q->sql,'"supply_records"'))$queries[]=$q->sql;});$first=$this->getJson('/api/stock-returns')->assertOk()->assertJsonPath('pagination.total',51)->assertJsonPath('pagination.last_page',3)->assertJsonCount(25,'rows')->json('rows');$capture=false;$this->assertLessThanOrEqual(4,count($queries));
+ $second=$this->getJson('/api/stock-returns?page=2')->assertOk()->assertJsonCount(25,'rows')->json('rows');$this->assertEmpty(array_intersect(array_column($first,'id'),array_column($second,'id')));$this->assertGreaterThan($second[0]['id'],$first[24]['id']);$this->getJson('/api/stock-returns?page=3')->assertOk()->assertJsonCount(1,'rows');
+ $this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',50);$this->getJson('/api/stock-returns?state=confirmed')->assertOk()->assertJsonPath('pagination.total',1);$this->assertSame(8000,$this->balance());
+ file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/return-history-metrics.json',json_encode(['fixture_records'=>51,'page_size'=>25,'supply_record_selects_for_page'=>count($queries),'pages_disjoint'=>true,'state_filters_cover_latest_versions'=>true,'not_a_load_capacity_benchmark'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/tests/Feature/WorkflowTaskInboxTest.php b/tests/Feature/WorkflowTaskInboxTest.php
new file mode 100644
index 0000000..4d052d9
--- /dev/null
+++ b/tests/Feature/WorkflowTaskInboxTest.php
@@ -0,0 +1,168 @@
+f = receiptFixture('100.000');
+ $this->head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id]);
+ supplyGrant($this->f['issuer'], ['requester', 'warehouse_request_prepare']);
+ supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
+ supplyGrant($this->head, ['department_head']);
+ supplyGrant($this->head, ['warehouse_request_review']);
+ supplyGrant($this->f['otherReceiver'], ['admin']);
+ $this->actingAs($this->f['receiver']);
+ app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
+ app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']),
+ 'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Publicação sintética para reutilizar o cadastro nas tarefas.']);
+});
+
+function taskInboxRequest($test, string $quantity = '4.000'): void
+{
+ $test->actingAs($test->f['issuer']);
+ $test->r = app(WarehouseRequests::class)->draft($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $test->f['warehouse']->id,
+ 'cost_center_id' => $test->f['r']->cost_center_id, 'priority' => 'normal', 'destination' => 'Oficina sintética', 'reason' => 'Material para manutenção no ensaio de tarefas.',
+ 'lines' => [['stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'use' => 'internal']]]);
+ taskInboxCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
+}
+
+function taskInboxCommand($test, User $actor, string $action, array $extra = [])
+{
+ $test->actingAs($actor);
+
+ return app(WarehouseFulfilment::class)->command($actor, $test->r->id, ['operation_key' => (string) Str::uuid(),
+ 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'], 'action' => $action, 'reason' => 'Intervenção efectiva no ensaio das tarefas.', ...$extra]);
+}
+
+function taskInboxPurchase($test)
+{
+ $test->actingAs($test->f['issuer']);
+ $entry = DB::table('requisition_catalogue')->where('stock_item_id', $test->f['stock']->id)->sole();
+
+ return app(RequisitionWorkflow::class)->save($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $test->f['r']->cost_center_id,
+ 'priority' => 'normal', 'needed_by' => today()->addWeek()->toDateString(), 'justification' => 'Compra sintética para validar o encaminhamento de tarefas.',
+ 'items' => [['catalogue_entry_id' => $entry->id, 'catalogue_version' => $entry->version, 'quantity' => '2.000', 'unit_price' => '10.00', 'tax_rate' => '0']]]);
+}
+
+test('task inbox connects purchasing draft and real human approval without granting administrative execution', function () {
+ $purchase = taskInboxPurchase($this);
+ $this->get('/requisicoes/tarefas')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'prepare')->where('tasks.data.0.href', '/requisicoes/'.$purchase->id));
+ app(RequisitionWorkflow::class)->submit($this->f['issuer'], $purchase);
+ $this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->head)->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'approve'));
+ $this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->head);
+ app(RequisitionWorkflow::class)->decide($this->head, $purchase->fresh(), 'approved', 'Conferência humana no ensaio sintético.');
+ $this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+});
+
+test('warehouse review delivery and individual receipt tasks follow the existing commands including cancelled balances', function () {
+ taskInboxRequest($this);
+ $this->actingAs($this->head)->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'review'));
+ taskInboxCommand($this, $this->head, 'authorize', ['partial_allowed' => true]);
+ $this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'delivery')->where('tasks.data.0.blocked', false));
+ $first = taskInboxCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
+ $second = taskInboxCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
+ taskInboxCommand($this, $this->head, 'cancel');
+ $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)
+ ->where('tasks.data.0.href', '/stock/requisicoes/'.$this->r->id.'#warehouse-command-receive-'.$first->id)
+ ->where('tasks.data.1.href', '/stock/requisicoes/'.$this->r->id.'#warehouse-command-receive-'.$second->id));
+ taskInboxCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $first->id]);
+ $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
+ taskInboxCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $second->id]);
+ $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+});
+
+test('reservation deficits and blocked delivery remain visible to the appropriate people', function () {
+ taskInboxRequest($this, '25.000');
+ taskInboxCommand($this, $this->head, 'authorize', ['partial_allowed' => true]);
+ $this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.data.0.task', 'reserve'));
+ $this->f['stock']->update(['name' => 'Cadastro alterado depois da autorização']);
+ $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.data.0.blocked', true)->where('tasks.data.0.href', '/stock/requisicoes/'.$this->r->id));
+ $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+});
+
+test('task filtering pagination and oldest records use the whole authorised scope', function () {
+ $first = null;
+ foreach (range(1, 30) as $i) {
+ $purchase = taskInboxPurchase($this);
+ $first ??= $purchase;
+ }
+ taskInboxRequest($this);
+ $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 25)->where('tasks.data.0.reference', $first->number));
+ $this->get('/requisicoes/tarefas?page=2')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 5));
+ $this->get('/requisicoes/tarefas?search='.urlencode($first->number))->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('counts.purchases', 30));
+ $this->actingAs($this->head)->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('counts.purchases', 0)->where('counts.warehouse', 1));
+});
+
+test('consulting tasks does not record decisions read receipts reservations or stock changes', function () {
+ taskInboxRequest($this);
+ $this->actingAs($this->head);
+ $tables = ['warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'requisitions', 'approval_steps', 'audit_logs', 'supply_records'];
+ $before = [];
+ foreach ($tables as $table) {
+ $before[$table] = DB::table($table)->get()->toArray();
+ }
+ $this->get('/requisicoes/tarefas')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private');
+ foreach ($tables as $table) {
+ expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
+ }
+ $profile = DB::table('organization_profiles')->whereJsonContains('permissions', 'warehouse_request_review')->value('id');
+ DB::table('organization_memberships')->where('user_id', $this->head->id)->where('profile_id', $profile)->update(['expires_at' => now()->subMinute()]);
+ $this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+});
+
+test('people tasks reuse the assigned team and stay separate from purchasing and warehouse', function () {
+ $owner = $this->f['otherReceiver'];
+ $reviewer = User::factory()->create(['tenant_id' => $owner->tenant_id, 'department_id' => $owner->department_id]);
+ supplyGrant($reviewer, ['people_service_review']);
+ $this->actingAs($owner);
+ $process = app(PeopleHiring::class)->execute($owner, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Contratação sintética do ensaio',
+ 'purpose' => 'Necessidade documentada para testar a equipa responsável.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
+ app(PeopleAccess::class)->insert($process, $reviewer->id, ['view', 'review'], 'Equipa atribuída no ensaio descartável.');
+ $this->actingAs($reviewer)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('area', 'people')->where('tasks.total', 1)->where('tasks.data.0.module', 'people')->where('tasks.data.0.task', 'open'));
+ $this->getJson('/pessoas/tarefas?module=warehouse')->assertUnprocessable();
+ $this->actingAs($owner)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $this->actingAs($reviewer);
+ app(PeopleHiring::class)->execute($reviewer, $process->id, 'open', ['version' => $process->version, 'no_conflict' => true, 'reason' => 'Conferência independente da abertura no ensaio.']);
+ $this->actingAs($owner)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'apply'));
+ DB::table('people_process_access')->where('people_process_id', $process->id)->where('user_id', $owner->id)->update(['expires_at' => now()->subMinute()]);
+ $this->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+});
+
+test('tasks do not cross company or the currently selected work context', function () {
+ taskInboxRequest($this);
+ $foreign = receiptFixture();
+ supplyGrant($foreign['issuer'], ['admin']);
+ $this->actingAs($foreign['issuer'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ $actor = $this->f['otherReceiver'];
+ $this->f['tenant']->update(['work_context_policy' => ['revision' => 1, 'enabled' => ['people.internal', 'procurement.internal']]]);
+ config(['work_context.entitlements.'.$actor->tenant_id => ['people.internal', 'procurement.internal']]);
+ DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->update(['work_contexts' => json_encode(['people.internal', 'procurement.internal'])]);
+ $this->actingAs($actor)->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
+ $this->get('/requisicoes/tarefas')->assertForbidden();
+ $this->get('/pessoas/tarefas')->assertOk();
+ $this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
+ $this->get('/pessoas/tarefas')->assertForbidden();
+});
+
+test('temporary maintenance does not receive operational decisions even with the corresponding profile', function () {
+ $purchase = taskInboxPurchase($this);
+ app(RequisitionWorkflow::class)->submit($this->f['issuer'], $purchase);
+ DB::table('organization_memberships')->where('user_id', $this->head->id)->update(['maintenance' => true]);
+ $this->actingAs($this->head)->get('/requisicoes/tarefas')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
+ expect($purchase->fresh()->status)->toBe('pending');
+});
diff --git a/tests/Support/InboxFixture.php b/tests/Support/InboxFixture.php
new file mode 100644
index 0000000..136f9b9
--- /dev/null
+++ b/tests/Support/InboxFixture.php
@@ -0,0 +1,66 @@
+ PDO::ERRMODE_EXCEPTION]);
+ if ($db->query('SELECT current_database()')->fetchColumn() !== $name) {
+ throw new LogicException('Destino de teste divergente.');
+ }
+ } else {
+ $db = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
+ }
+
+ return $db;
+}
+
+function inboxDatabase(): PDO
+{
+ $db = inboxConnection();
+ if (getenv('INBOX_TEST_PG') === '1') {
+ $db->exec('DROP TABLE IF EXISTS sgc_exchange_facts');
+ $db->exec('DROP TABLE IF EXISTS sgc_exchange_streams');
+ }
+ foreach (WarehouseInbox::schema() as $sql) {
+ $db->exec($sql);
+ }
+
+ return $db;
+}
+
+function inboxEvent(int $id = 1, string $action = 'deliver', ?string $delivery = null): array
+{
+ $event = ['schema' => 'sgc.warehouse.fulfilment-event.v1', 'idempotency_key' => 'sgc:7:warehouse-event:'.$id,
+ 'tenant_id' => 7, 'event_id' => $id, 'event_version' => $id + 2, 'action' => $action,
+ 'occurred_at' => '2026-11-02T10:00:00+00:00', 'actor' => ['id' => 3, 'name' => 'Operador sintético'],
+ 'request' => ['id' => 19, 'reference' => 'RA-19', 'document_type' => 'internal_warehouse_request',
+ 'work_context_key' => 'procurement.internal', 'requester' => ['id' => 5, 'name' => 'Requisitante sintético', 'department_id' => 2],
+ 'warehouse' => ['id' => 1, 'code' => 'ARM', 'name' => 'Armazém sintético'], 'cost_center' => ['id' => 2, 'code' => 'CC', 'name' => 'Centro sintético'], 'destination' => 'Oficina sintética'],
+ 'lines' => $action === 'deliver' ? [['request_line_index' => 0, 'item' => ['id' => 4, 'code' => 'ART', 'name' => 'Artigo sintético', 'unit' => 'un'],
+ 'quantity_milli' => 1500, 'use' => 'internal', 'destination' => 'Oficina sintética', 'equipment' => null, 'stock_operation_id' => $id + 10]] : [],
+ 'delivery_event_key' => $delivery];
+
+ return inboxSign($event);
+}
+
+function inboxSign(array $event): array
+{
+ unset($event['payload_sha256']);
+
+ return [...$event, 'payload_sha256' => hash('sha256', WarehouseFeed::json($event))];
+}
+
+function inboxPage(array $events, string $cursor = 'synthetic-opaque-cursor', bool $more = false): array
+{
+ return ['schema' => 'sgc.warehouse.fulfilment-feed.v1', 'items' => $events, 'next_cursor' => $cursor, 'has_more' => $more];
+}
diff --git a/tests/Support/common-receipt-worker.php b/tests/Support/common-receipt-worker.php
new file mode 100644
index 0000000..64424c7
--- /dev/null
+++ b/tests/Support/common-receipt-worker.php
@@ -0,0 +1,16 @@
+make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS name,current_user AS role,current_setting('cluster_name') AS cluster");
+if(!preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name,$m)||$i->role!=='stock_receipt_qa_'.$m[1]||$i->cluster!=='sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'))throw new RuntimeException('Wrong receipt worker database');
+$input=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);
+$db->select("SELECT set_config('application_name',?,false)",[$input['application']]);$db->statement("SET lock_timeout='8s'");$db->statement("SET statement_timeout='12s'");
+$actor=App\Models\User::findOrFail($input['actor']);auth()->guard()->setUser($actor);
+file_put_contents($file.'.ready','ready');
+$start=hrtime(true);
+try{$receipt=app(App\Services\ReceiptWorkflow::class)->confirm($actor,App\Models\PurchaseOrder::findOrFail($input['order']),$input['payload']);
+ $result=['status'=>200,'receipt_id'=>$receipt->id];
+}catch(Illuminate\Validation\ValidationException){$result=['status'=>422];}
+catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$result=['status'=>$e->getStatusCode()];}
+$result['elapsed_ms']=round((hrtime(true)-$start)/1e6,3);file_put_contents($file.'.result',json_encode($result,JSON_THROW_ON_ERROR));
diff --git a/tests/Support/inbox-pg-races.php b/tests/Support/inbox-pg-races.php
new file mode 100644
index 0000000..fba146e
--- /dev/null
+++ b/tests/Support/inbox-pg-races.php
@@ -0,0 +1,74 @@
+ [...$config, 'database' => $database, 'url' => null]]);
+ $reader = DB::connection('inbox_proof_read');
+ $worker = fn (string $mode, int $id = 1) => new Process([PHP_BINARY, 'tests/Concurrency/inbox-worker.php', $mode, (string) $id], dirname(__DIR__, 2), $environment, timeout: 20);
+ $races = [];
+ try {
+ foreach (['same_page', 'different_pages'] as $scenario) {
+ $worker('prepare')->mustRun();
+ $first = $worker('hold');
+ $first->start();
+ $deadline = microtime(true) + 5;
+ while ($first->isRunning() && ! str_contains($first->getOutput(), 'locked') && microtime(true) < $deadline) {
+ usleep(20000);
+ }
+ if (! str_contains($first->getOutput(), 'locked')) {
+ throw new RuntimeException('Primeiro receptor sem bloqueio demonstrado.');
+ }
+ $second = $worker('run', $scenario === 'same_page' ? 1 : 2);
+ $second->start();
+ $blocked = false;
+ $deadline = microtime(true) + 5;
+ while ($first->isRunning() && microtime(true) < $deadline) {
+ $blocked = (bool) $reader->selectOne("SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE datname=? AND wait_event_type='Lock') AS blocked", [$database])->blocked;
+ if ($blocked) {
+ break;
+ }
+ usleep(20000);
+ }
+ $first->wait();
+ $second->wait();
+ if (! $first->isSuccessful() || ! $second->isSuccessful() || ! $blocked) {
+ throw new RuntimeException('Disputa sem execução/bloqueio confirmados.');
+ }
+ $a = json_decode(str_replace('locked', '', $first->getOutput()), true, 16, JSON_THROW_ON_ERROR);
+ $b = json_decode($second->getOutput(), true, 16, JSON_THROW_ON_ERROR);
+ $accepted = ($a['result'] === 'accepted' ? 1 : 0) + ($b['result'] === 'accepted' ? 1 : 0);
+ if ($accepted !== ($scenario === 'same_page' ? 2 : 1)
+ || ($scenario === 'same_page' && (($a['inserted'] + $b['inserted']) !== 1 || ($a['replayed'] === $b['replayed'])))
+ || (int) $reader->table('sgc_exchange_facts')->count() !== 1
+ || (int) $reader->table('sgc_exchange_streams')->value('version') !== 2) {
+ throw new RuntimeException('Disputa duplicou factos ou fez avançar o cursor indevidamente.');
+ }
+ $races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'facts' => 1, 'checkpoint_version' => 2, 'result' => 'passed'];
+ }
+ $worker('prepare')->mustRun();
+ $interrupted = $worker('exit-after-commit');
+ $interrupted->run();
+ if ($interrupted->getExitCode() !== 42) {
+ throw new RuntimeException('Interrupção após commit não demonstrada.');
+ }
+ $retry = $worker('run');
+ $retry->mustRun();
+ $result = json_decode($retry->getOutput(), true, 16, JSON_THROW_ON_ERROR);
+ if (($result['replayed'] ?? false) !== true || $result['inserted'] !== 0 || (int) $reader->table('sgc_exchange_facts')->count() !== 1
+ || (int) $reader->table('sgc_exchange_streams')->value('version') !== 2) {
+ throw new RuntimeException('Retry após perda da confirmação não foi idempotente.');
+ }
+ $races[] = ['scenario' => 'process_exit_after_commit_before_acknowledgement', 'result' => 'passed', 'facts' => 1, 'checkpoint_version' => 2];
+ } finally {
+ DB::purge('inbox_proof_read');
+ }
+
+ return $races;
+}
diff --git a/tests/Support/sgf-admin-candidate-checks.php b/tests/Support/sgf-admin-candidate-checks.php
new file mode 100644
index 0000000..7f62690
--- /dev/null
+++ b/tests/Support/sgf-admin-candidate-checks.php
@@ -0,0 +1,44 @@
+\App\Models\User::findOrFail($head)->email,'unit_id'=>$scope['unit_id'],'permission'=>'purchasing_budget.consume','reason'=>'Forbidden delegation outside own synthetic authority'])['status']===403,'forged delegation of an unowned catalogue right refused');
+
+$uiManifest=json_decode(file_get_contents($clone.'/public/build/manifest.json'),true,32,JSON_THROW_ON_ERROR);
+foreach($uiManifest as $chunk)foreach([$chunk['file'],...($chunk['css']??[])] as $file){
+ $asset=$anonymous->get('http://127.0.0.1:'.$port.'/build/'.$file);
+ $check($asset->getStatusCode()===200&&strlen((string)$asset->getBody())>100,'packaged frontend dependency served before authentication');
+}
+$main=$anonymous->get('http://127.0.0.1:'.$port.'/');
+$check($main->getStatusCode()===200&&str_contains((string)$main->getBody(),'/build/modules/internal-requisitions/assets/'),'native SGF entry renders the packaged frontend manifest');
+$check($anonymous->get('http://127.0.0.1:'.$port.'/build/modules/internal-requisitions/assets/missing.js')->getStatusCode()===404,'undeclared frontend asset refused');
+
+// Reproduce destination-style broad inherited ACLs on NEW objects, then prove
+// the proposed map narrows them without touching any of the 87 prior tables.
+$beforeAcl=[];
+foreach($inventory['tables'] as $table)$beforeAcl[$table]=$db->selectOne('SELECT relacl::text AS acl FROM pg_class WHERE oid=?::regclass',['public.'.$table])->acl;
+foreach(\Tempo\Purchasing\Delivery\SqlPrivileges::TABLES as $table=>$rights)$db->statement('GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE public.'.$q($table).' TO '.$q($runtime));
+foreach(\Tempo\Purchasing\Delivery\SqlPrivileges::plan($runtime)['statements'] as $sql)$db->statement($sql);
+$check(!$rt->selectOne("SELECT has_table_privilege(current_user,'cb_centres','UPDATE') AS allowed")->allowed&&!$rt->selectOne("SELECT has_table_privilege(current_user,'cb_audit','DELETE') AS allowed")->allowed,'minimal plan removes inherited update and delete on new append-only sources');
+$check(!$rt->selectOne("SELECT has_table_privilege(current_user,'ir_requests','TRUNCATE') AS allowed")->allowed,'runtime cannot truncate requisitions');
+foreach($beforeAcl as $table=>$acl)$check($db->selectOne('SELECT relacl::text AS acl FROM pg_class WHERE oid=?::regclass',['public.'.$table])->acl===$acl,'existing table ACL preserved '.$table);
+try{$rt->transaction(fn()=>$rt->statement('CREATE TABLE forbidden_runtime_ddl (id integer)'));throw new LogicException('Unexpected runtime DDL');}
+catch(\Illuminate\Database\QueryException $e){$check($e->errorInfo[0]==='42501','actual runtime CREATE denied');}
+try{$rt->transaction(fn()=>$rt->statement('ALTER TABLE permission_grants ADD COLUMN forbidden_runtime integer'));throw new LogicException('Unexpected runtime ALTER');}
+catch(\Illuminate\Database\QueryException $e){$check($e->errorInfo[0]==='42501','actual runtime ALTER of identity catalogue denied');}
+
+$badCentre=['operation_key'=>(string)\Illuminate\Support\Str::uuid(),'code'=>'NOT-ALLOWED','reason'=>'Validation in the isolated reproduction only'];
+$check($call($head,'/api/internal-requisitions/centres','POST',$badCentre)['status']===403,'centre creation requires own configuration and budget rights');
+$check($call($actor,'/api/internal-requisitions/centres','POST',$badCentre+['company_id'=>999999])['status']===422,'centre context cannot be forged');
+$check(\Illuminate\Support\Facades\DB::table('cb_centres')->count()===1,'refused centre operations leave original source intact');
+foreach(['equipment','part'] as $unsupported){
+ $r=$call($actor,'/api/internal-requisitions/save','POST',['operation_key'=>(string)\Illuminate\Support\Str::uuid(),'cost_center_id'=>$f['centre'],'priority'=>'normal','needed_by'=>date('Y-m-d',time()+864000),'justification'=>'Unsupported type must remain outside this increment.','items'=>[['description'=>'Unsupported source','category'=>'Excluded','purchase_type'=>$unsupported,'quantity'=>'1.000','unit'=>'UN','unit_price'=>'1.00','tax_rate'=>'0.00']]]);
+ $check($r['status']===422,'unsupported requisition type refused '.$unsupported);
+}
+$data=json_decode(\Illuminate\Support\Facades\DB::table('ir_requests')->where('id',$id)->value('data'),true,32,JSON_THROW_ON_ERROR);
+$check(array_column($data['items'],'purchase_type')===['consumable','service','other'],'approved lifecycle covers the three supported basic types');
+$check(\Illuminate\Support\Facades\DB::table('ir_requests')->count()===1&&\Illuminate\Support\Facades\DB::table('cb_budget_entries')->where('kind','reserve')->count()===1,'unsupported types create neither a second request nor reservation');
diff --git a/tests/Support/sgf-corrected-export.php b/tests/Support/sgf-corrected-export.php
new file mode 100644
index 0000000..ae0cc49
--- /dev/null
+++ b/tests/Support/sgf-corrected-export.php
@@ -0,0 +1,49 @@
+make(Kernel::class)->bootstrap();
+try {
+ $db = DB::connection();
+ $db->beginTransaction();
+ $db->statement('SET TRANSACTION READ ONLY');
+ $tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
+ $acl = [];
+ foreach ($tables as $table) {
+ $rights = [];
+ foreach (['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REFERENCES', 'TRIGGER', 'TRUNCATE'] as $right) {
+ if ($db->selectOne('select has_table_privilege(current_user,?,?) as allowed', ['public.'.$table, $right])->allowed) {
+ $rights[] = $right;
+ }
+ }$acl[$table] = $rights;
+ }
+ $fingerprints = [];
+ foreach (['permission_grants', 'company_memberships', 'identity_audit'] as $table) {
+ $rows = $db->select('select to_jsonb(t)::text as value from "'.$table.'" t order by to_jsonb(t)::text');
+ $fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'value'), JSON_THROW_ON_ERROR));
+ }
+ $functions = $db->select("select p.oid::regprocedure::text as identity from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and has_function_privilege(current_user,p.oid,'EXECUTE') order by 1");
+ $config = $db->getConfig();
+ $db->rollBack();
+ if (getenv('FIX_CHECK_ONLY') === '1') {
+ echo json_encode(['source_security_fingerprints' => $fingerprints, 'tables' => $tables, 'database' => $config['database'], 'read_only' => true], JSON_THROW_ON_ERROR);
+ exit(0);
+ }
+ $dump = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--schema-only', '--format=custom', '--no-owner', '--no-privileges', '--no-comments', '--file='.$output, '--dbname='.$config['database']], null,
+ ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGOPTIONS' => '-c default_transaction_read_only=on'], null, 60);
+ $dump->mustRun();
+ echo json_encode(['database' => $config['database'], 'tables' => $tables, 'runtime_table_acl' => $acl, 'runtime_functions' => array_column($functions, 'identity'), 'source_security_fingerprints' => $fingerprints, 'schema_sha256' => hash_file('sha256', $output), 'rows_exported' => 0, 'read_only' => true], JSON_THROW_ON_ERROR);
+} catch (Throwable $error) {
+ if (isset($db) && $db->transactionLevel() > 0) {
+ $db->rollBack();
+ }fwrite(STDERR, 'Schema-only export failed: '.get_class($error).($error instanceof QueryException ? ' SQLSTATE '.($error->errorInfo[0] ?? '').'; '.$error->getSql() : '')."\n");
+ exit(1);
+}
diff --git a/tests/Support/sgf-corrected-proof.php b/tests/Support/sgf-corrected-proof.php
new file mode 100644
index 0000000..d7becc5
--- /dev/null
+++ b/tests/Support/sgf-corrected-proof.php
@@ -0,0 +1,272 @@
+make(Kernel::class)->bootstrap();
+$bundle = getenv('FIX_BUNDLE');
+$work = getenv('FIX_WORK');
+$runtime = getenv('FIX_RUNTIME');
+$runtimePassword = getenv('FIX_RUNTIME_PASSWORD');
+require $bundle.'/integrations/sgf/operational/bootstrap.php';
+require $bundle.'/integrations/sgf/operational/CodeDeployment.php';
+$inventory = json_decode(file_get_contents($work.'/baseline.json'), true, 64, JSON_THROW_ON_ERROR);
+$db = DB::connection();
+if (! preg_match('/^sgf_reproduction_([a-f0-9]{12})$/D', $db->getDatabaseName(), $identity) || $runtime !== 'fix_app_'.$identity[1] || $db->selectOne('select current_user as name')->name !== 'fix_owner_'.$identity[1]) {
+ throw new RuntimeException('Disposable reproduction identity required before any test grant or write.');
+}
+$checks = [];
+$check = function ($ok, $label) use (&$checks) {
+ if (! $ok) {
+ throw new RuntimeException($label);
+ }$checks[] = $label;
+};
+$tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
+$check($tables === $inventory['tables'], 'reproduced current 87-table schema without source rows');
+$check(DB::table('users')->count() === 0 && DB::table('permission_grants')->count() === 0, 'no accounts or grants imported from destination');
+$q = fn ($name) => '"'.str_replace('"', '""', $name).'"';
+$db->statement('GRANT USAGE ON SCHEMA public TO '.$q($runtime));
+foreach ($inventory['runtime_table_acl'] as $table => $rights) {
+ if ($rights) {
+ $db->statement('GRANT '.implode(', ', $rights).' ON TABLE public.'.$q($table).' TO '.$q($runtime));
+ }
+ if (in_array('INSERT', $rights, true) && $db->getSchemaBuilder()->hasColumn($table, 'id')) {
+ $seq = $db->selectOne('select pg_get_serial_sequence(?,?) as name', ['public.'.$table, 'id'])->name;
+ if ($seq) {
+ $db->statement('GRANT USAGE ON SEQUENCE '.$seq.' TO '.$q($runtime));
+ }
+ }
+}
+$db->statement('REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC');
+foreach ($inventory['runtime_functions'] as $function) {
+ $db->statement('GRANT EXECUTE ON FUNCTION '.$function.' TO '.$q($runtime));
+}
+config(['database.connections.fixed_runtime' => [...$db->getConfig(), 'url' => null, 'username' => $runtime, 'password' => $runtimePassword]]);
+$rt = DB::connection('fixed_runtime');
+$check(! $rt->selectOne("select has_schema_privilege(current_user,'public','CREATE') as allowed")->allowed, 'runtime cannot create schema objects');
+$now = now();
+$tenant = DB::table('tenants')->insertGetId(['name' => 'Empresa sintética da reprodução', 'created_at' => $now, 'updated_at' => $now]);
+$company = DB::table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Horizonte sintético', 'created_at' => $now, 'updated_at' => $now]);
+$unit = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Unidade sintética', 'created_at' => $now, 'updated_at' => $now]);
+$password = bin2hex(random_bytes(24));
+$actor = User::create(['name' => 'Preparador sintético', 'email' => 'prepare-'.$unit.'@example.test', 'password' => $password])->id;
+$head = User::create(['name' => 'Aprovador sintético', 'email' => 'approve-'.$unit.'@example.test', 'password' => $password])->id;
+foreach ([$actor, $head] as $id) {
+ DB::table('company_memberships')->insert(['user_id' => $id, 'tenant_id' => $tenant, 'company_id' => $company]);
+ foreach (['company.view' => null, 'unit.view' => $unit] as $right => $u) {
+ DB::table('permission_grants')->insert(['user_id' => $id, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $u, 'permission' => $right, 'created_at' => $now, 'updated_at' => $now]);
+ }
+}
+DB::table('permission_grants')->insert(['user_id' => $actor, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => null, 'permission' => 'access.manage', 'created_at' => $now, 'updated_at' => $now]);
+$security = function () use ($db) {
+ $out = [];
+ foreach (['users', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) {
+ $out[$table] = hash('sha256', json_encode($db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text'), JSON_THROW_ON_ERROR));
+ }
+
+ return $out;
+};
+$before = $security();
+$target = Installation::target($db);
+$fault = true;
+DB::listen(function ($query) use (&$fault) {
+ if ($fault && str_contains($query->sql, 'create table "cb_ui_commands"')) {
+ $fault = false;
+ throw new RuntimeException('Injected corrected migration failure');
+ }
+});
+try {
+ Installation::migrate($db, $target, str_repeat('a', 64), 'Isolated migrator');
+ throw new LogicException('Expected migration failure');
+} catch (RuntimeException$error) {
+ $check($error->getMessage() === 'Injected corrected migration failure', 'injected dependency migration failure');
+}
+$check(! $db->getSchemaBuilder()->hasTable('ir_requests') && ! $db->getSchemaBuilder()->hasTable('cb_ui_commands') && $security() === $before, 'failed migration restores original schema and identity data');
+Installation::migrate($db, $target, str_repeat('a', 64), 'Isolated migrator');
+$check($security() === $before, 'successful migration preserves existing identity and grants');
+$check(DB::table('cb_centres')->count() === 0 && DB::table('cb_budgets')->count() === 0, 'installation creates empty centres and allocations');
+try {
+ $rt->table('sgc_installations')->count();
+ throw new LogicException('Unexpected module access');
+} catch (QueryException$error) {
+ $check($error->errorInfo[0] === '42501', 'runtime module SQL access denied before test-only privilege plan');
+}
+$privileges = SqlPrivileges::plan($runtime);
+foreach ($privileges['statements'] as $sql) {
+ $db->statement($sql);
+}
+$check(! $rt->selectOne("select has_table_privilege(current_user,'ir_events','UPDATE') as allowed")->allowed && ! $rt->selectOne("select has_table_privilege(current_user,'ir_requests','DELETE') as allowed")->allowed, 'minimal runtime excludes historical update and requisition deletion');
+$check($rt->table('sgc_installations')->value('revision') === 2, 'runtime reads corrected revision without ownership');
+$deploy = new CodeDeployment($clone);
+$mh = hash_file('sha256', $bundle.'/manifest.json');
+$plan = $deploy->plan($bundle, $mh);
+$applied = $deploy->apply($bundle, $mh, $plan['target_sha256'], 'Isolated code preparation', true);
+unlink($clone.'/storage/framework/down');
+file_put_contents($clone.'/config/purchasing-operational.php', 'true,"target"=>getenv("IR_HTTP_TARGET"),"scopes"=>[getenv("IR_HTTP_SCOPE")]];');
+file_put_contents($clone.'/config/purchasing-access.php', '[getenv("IR_HTTP_SCOPE")]];');
+$f = ['actor' => $actor, 'validator' => $head, 'scope' => ['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit]];
+$scope = $f['scope'];
+$target = Installation::target(DB::connection());
+$scopeKey = implode(':', $scope);
+config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => $target, 'purchasing-operational.scopes' => [$scopeKey]]);
+$actor = $f['actor'];
+$head = $f['validator'];
+DB::table('permission_grants')->where($scope)->where('user_id', $actor)->where('permission', 'like', 'purchasing_%')->delete();
+$password = bin2hex(random_bytes(24));
+foreach ([$actor, $head] as $id) {
+ $u = User::findOrFail($id);
+ $u->password = $password;
+ $u->save();
+}
+$socket = stream_socket_server('tcp://127.0.0.1:0');
+$address = stream_socket_get_name($socket, false);
+fclose($socket);
+$port = (int) substr(strrchr($address, ':'), 1);
+$server = new Process([PHP_BINARY, '-S', '127.0.0.1:'.$port, '-t', $clone.'/public', $root.'/tests/Support/sgf-composition-router.php'], $clone, ['IR_CLONE' => $clone, 'DB_USERNAME' => $runtime, 'DB_PASSWORD' => $runtimePassword, 'IR_HTTP_ENABLED' => '1', 'IR_HTTP_TARGET' => $target, 'IR_HTTP_SCOPE' => $scopeKey], null, 180);
+$server->start();
+$earlyChecks = $checks;
+$check = function ($ok, $label) use (&$checks) {
+ if (! $ok) {
+ throw new RuntimeException($label);
+ }$checks[] = $label;
+};
+$check(str_starts_with(str_replace('\\', '/', (new ReflectionClass(AppServiceProvider::class))->getFileName()), str_replace('\\', '/', $clone).'/app/'), 'provider really loaded from isolated copy');
+$plan = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $original.'/scripts/sgf.ps1', 'php', $clone.'/packages/internal-requisitions/scripts/sgf-internal-install.php', 'plan', $clone], $clone, null, null, 60);
+$plan->mustRun();
+$check(json_decode($plan->getOutput(), true, 16, JSON_THROW_ON_ERROR)['schema_prepared'], 'new bundle bootstrap accepted by installer integrity check');
+try {
+ for ($i = 0; $i < 100; $i++) {
+ if (@fsockopen('127.0.0.1', $port)) {
+ break;
+ }usleep(50000);
+ }
+ $clients = [];
+ $anonymous = new Client(['http_errors' => false]);
+ $check($anonymous->get('http://127.0.0.1:'.$port.'/purchasing/internal-requisitions/access-initialisation', ['headers' => ['Accept' => 'application/json']])->getStatusCode() === 401, 'anonymous direct initialisation URL denied');
+ $call = function ($id, $path, $method = 'GET', $data = null, $headers = []) use (&$clients, $port) {
+ $c = &$clients[$id];
+ $opts = ['http_errors' => false, 'headers' => array_merge(['Accept' => 'application/json', 'X-CSRF-TOKEN' => $c['csrf'], 'X-SGF-Context' => $c['token'] ?? '', 'X-Purchasing-Context' => 'procurement.internal'], $headers)];
+ if ($data !== null) {
+ $opts['json'] = $data;
+ }
+ $r = $c['http']->request($method, 'http://127.0.0.1:'.$port.$path, $opts);
+ $body = (string) $r->getBody();
+
+ return ['status' => $r->getStatusCode(), 'data' => json_decode($body, true), 'body' => $body];
+ };
+ foreach ([$actor, $head] as $id) {
+ $http = new Client(['cookies' => new CookieJar, 'timeout' => 15]);
+ $s = json_decode((string) $http->get('http://127.0.0.1:'.$port.'/api/session', ['headers' => ['Accept' => 'application/json']])->getBody(), true);
+ $clients[$id] = ['http' => $http, 'csrf' => $s['csrf']];
+ $login = $call($id, '/api/login', 'POST', ['email' => User::findOrFail($id)->email, 'password' => $password]);
+ $check($login['status'] === 200, 'native clone authentication '.$id);
+ $clients[$id]['csrf'] = $login['data']['csrf'];
+ $selected = $call($id, '/api/context', 'POST', $scope);
+ $check($selected['status'] === 200, 'native clone context '.$id);
+ $clients[$id]['token'] = $selected['data']['context']['token'];
+ }
+ $page = $call($actor, '/purchasing/internal-requisitions/access-initialisation');
+ $check($page['status'] === 200 && str_contains($page['body'], 'Administrador nominativo'), 'provider registers initial authority page without prior functional rights');
+ $initial = ['operation_key' => (string) Str::uuid(), 'reason' => 'Entrega sintética através da composição HTTP.', 'administrators' => [['user_id' => $actor, 'rights' => ['purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure', 'purchasing_requisitions.approve.department_head', 'purchasing_budget.record', 'purchasing_budget.view', 'purchasing_budget.create', 'purchasing_budget.approve']]]];
+ $check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial, ['X-CSRF-TOKEN' => 'bad'])['status'] === 419, 'initial authority CSRF enforced');
+ $check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial)['status'] === 200, 'initial authority through normal middleware');
+ $check($call($actor, '/purchasing/internal-requisitions')['status'] === 409, 'work page requires explicit module selection');
+ $oldToken = $clients[$actor]['token'];
+ $selected = $call($actor, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
+ $check($selected['status'] === 200, 'new requisition view right makes purchasing selectable');
+ $clients[$actor]['token'] = $selected['data']['context']['token'];
+ $check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-SGF-Context' => $oldToken])['status'] === 409, 'stale module generation refused');
+ $check($call($actor, '/purchasing/internal-requisitions')['status'] === 200, 'provider registers operational work page');
+ $check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-Purchasing-Context' => 'procurement.third_party'])['status'] === 403, 'different enterprise option refused by operational composition');
+ foreach (['purchasing_requisitions.view', 'purchasing_requisitions.approve.department_head', 'purchasing_budget.view', 'purchasing_budget.approve'] as $right) {
+ $r = $call($actor, '/api/accesses', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => $right, 'reason' => 'Atribuição sintética explícita.']);
+ $check($r['status'] === 200, 'native administrative delegation '.$right);
+ }
+ $selected = $call($head, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
+ $clients[$head]['token'] = $selected['data']['context']['token'];
+ $check($call($actor, '/purchasing/internal-requisitions/budget-setup')['status'] === 200, 'empty installation exposes authorised centre and budget preparation');
+ $centreCommand = ['operation_key' => (string) Str::uuid(), 'code' => 'SINTETICO-REPRO', 'reason' => 'Centro exclusivamente sintético da reprodução isolada.'];
+ $centre = $call($actor, '/api/internal-requisitions/centres', 'POST', $centreCommand);
+ $check($centre['status'] === 200, 'centre created through authorised UI API with runtime role');
+ $f['centre'] = $centre['data']['centre_id'];
+ $repeat = $call($actor, '/api/internal-requisitions/centres', 'POST', $centreCommand);
+ $check($repeat['status'] === 200 && $repeat['data']['replayed'] && DB::table('cb_centres')->count() === 1, 'centre retries preserve one source');
+ $budgetData = ['cost_center_id' => $f['centre'], 'year' => (int) date('Y'), 'kind' => 'operating', 'includes_tax' => true, 'amount' => '10000.00', 'reason' => 'Dotação exclusivamente sintética, sem valores de destino.'];
+ $prepared = $call($actor, '/api/purchasing-budget/create', 'POST', $budgetData, ['Idempotency-Key' => (string) Str::uuid()]);
+ $check($prepared['status'] === 200, 'existing budget API prepares synthetic allocation');
+ $budgetId = DB::table('cb_budgets')->value('id');
+ $check($call($actor, '/api/purchasing-budget/approve', 'POST', ['budget_id' => $budgetId], ['Idempotency-Key' => (string) Str::uuid()])['status'] !== 200, 'budget approval requires independent identity');
+ $approvedBudget = $call($head, '/api/purchasing-budget/approve', 'POST', ['budget_id' => $budgetId], ['Idempotency-Key' => (string) Str::uuid()]);
+ $check($approvedBudget['status'] === 200, 'independent allocation approval uses existing budget kernel');
+ $list = $call($actor, '/api/purchasing-budget');
+ $check($list['status'] === 200 && count($list['data']['centres']) === 1, 'workspace dependencies available on reproduced schema');
+ $check($call($actor, '/api/purchasing-budget/movement', 'POST', [], ['Idempotency-Key' => (string) Str::uuid()])['status'] === 403, 'direct budget movements cannot bypass requisition lifecycle');
+ $policy = ['version' => 0, 'reason' => 'Política sintética na composição integrada.', 'enabled' => true, 'budget_required' => true, 'first_limit_minor' => 1000000, 'second_limit_minor' => 2000000, 'approval_includes_tax' => true, 'centres' => [['id' => $f['centre'], 'department_ref' => 'OPER']], 'members' => [['user_id' => $actor, 'department_ref' => 'OPER'], ['user_id' => $head, 'department_ref' => 'OPER']]];
+ $check($call($actor, '/api/internal-requisitions/policy', 'POST', $policy)['status'] === 200, 'operational policy configured via HTTP');
+ $draft = $call($actor, '/api/internal-requisitions/save', 'POST', ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $f['centre'], 'priority' => 'normal', 'needed_by' => date('Y-m-d', time() + 864000), 'justification' => 'Requisição sintética da composição.', 'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '2.000', 'unit' => 'UN', 'unit_price' => '50.00', 'tax_rate' => '16.00'], ['description'=>'Servi?o sint?tico','category'=>'Servi?os','purchase_type'=>'service','quantity'=>'1.000','unit'=>'UN','unit_price'=>'25.00','tax_rate'=>'0.00'], ['description'=>'Outro sint?tico','category'=>'Outros','purchase_type'=>'other','quantity'=>'1.000','unit'=>'UN','unit_price'=>'10.00','tax_rate'=>'0.00']]]);
+ $check($draft['status'] === 200, 'integrated operational draft');
+ $id = $draft['data']['id'];
+ $submitted = $call($actor, "/api/internal-requisitions/$id/submit", 'POST', ['operation_key' => (string) Str::uuid(), 'version' => 1]);
+ $check($submitted['status'] === 200 && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'integrated submit uses existing budget adapter');
+ $decision = ['operation_key' => (string) Str::uuid(), 'version' => $submitted['data']['version'], 'decision' => 'approved'];
+ $check($call($actor, "/api/internal-requisitions/$id/decide", 'POST', $decision)['status'] === 403, 'integrated independent identity enforced');
+ $approved = $call($head, "/api/internal-requisitions/$id/decide", 'POST', $decision);
+ $check($approved['status'] === 200 && $approved['data']['status'] === 'approved', 'integrated approval by another identity');
+ $view = $call($actor, "/api/internal-requisitions/$id");
+ $check($view['status'] === 200 && count($view['data']['history']) === 3, 'integrated authorised consultation and history');
+ require __DIR__.'/sgf-admin-candidate-checks.php';
+ $revoke = $call($actor, '/api/accesses/revoke', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => 'purchasing_requisitions.view', 'reason' => 'Revogação sintética explícita.']);
+ $check($revoke['status'] === 200 && $call($head, "/api/internal-requisitions/$id")['status'] === 403, 'native revocation applies to direct module URL');
+ file_put_contents($clone.'/config/modules.php', '[]];');
+ // Deployment configuration changes require restarting persistent PHP services.
+ $server->stop(1);
+ $server->start();
+ for ($i = 0; $i < 100; $i++) {
+ if (@fsockopen('127.0.0.1', $port)) {
+ break;
+ }
+ usleep(50000);
+ }
+ $check($call($actor, "/api/internal-requisitions/$id")['status'] === 403, 'uncontracted module refused at server');
+ file_put_contents($clone.'/config/modules.php', '["purchasing"]];');
+ file_put_contents($clone.'/config/purchasing-operational.php', 'false];');
+ $server->stop(1);
+ $server->start();
+ for ($i = 0; $i < 100; $i++) {
+ if (@fsockopen('127.0.0.1', $port)) {
+ break;
+ }usleep(50000);
+ }
+ $check($call($actor, '/purchasing/internal-requisitions')['status'] === 404, 'disabled composition exposes no executable work page');
+ $fingerprints = [];
+ foreach (array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename') as $table) {
+ $rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
+ $fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR));
+ }
+ $backup = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--format=custom', '--file='.$work.'/installed.dump', '--dbname='.$db->getDatabaseName()], null, ['PGHOST' => $db->getConfig('host'), 'PGPORT' => (string) $db->getConfig('port'), 'PGUSER' => $db->getConfig('username'), 'PGPASSWORD' => $db->getConfig('password')], null, 60);
+ $backup->mustRun();
+ file_put_contents($clone.'/storage/framework/down', '{"status":503}');
+ $server->stop(1);
+ require __DIR__.'/sgf-corrected-update.php';
+ $deploy->rollback($applied['deployment_id'], 'Isolated corrected code recovery', true);
+ $check(DB::table('ir_requests')->where('id', $id)->value('status') === 'approved' && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'code recovery preserves submitted origin decision and reservation');
+ $check(! is_dir($clone.'/packages/internal-requisitions') && is_file($clone.'/storage/framework/down'), 'code recovery leaves baseline and maintenance intact');
+ echo json_encode(['passed' => true, 'checks' => $checks, 'clone' => $clone, 'data_fingerprints' => $fingerprints, 'destination_activated' => false, 'normal_provider_routes' => true, 'browser_used' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
+} finally {
+ $server->stop(1);
+}
diff --git a/tests/Support/sgf-corrected-restored.php b/tests/Support/sgf-corrected-restored.php
new file mode 100644
index 0000000..3e4629e
--- /dev/null
+++ b/tests/Support/sgf-corrected-restored.php
@@ -0,0 +1,60 @@
+make(Kernel::class)->bootstrap();
+$bundle = getenv('FIX_BUNDLE');
+require $bundle.'/integrations/sgf/operational/bootstrap.php';
+$db = DB::connection();
+$runtime = getenv('FIX_RUNTIME');
+if (! preg_match('/^sgf_reproduction_restore_[a-f0-9]{12}$/D', $db->getDatabaseName()) || ! preg_match('/^fix_app_[a-f0-9]{12}$/D', $runtime)) {
+ throw new RuntimeException('Isolated recovery target required.');
+}
+$baseline = json_decode(file_get_contents(getenv('FIX_WORK').'/baseline.json'), true, 64, JSON_THROW_ON_ERROR);
+$q = fn ($name) => '"'.str_replace('"', '""', $name).'"';
+$db->statement('GRANT USAGE ON SCHEMA public TO '.$q($runtime));
+foreach ($baseline['runtime_table_acl'] as $table => $rights) {
+ if ($rights) {
+ $db->statement('GRANT '.implode(', ', $rights).' ON TABLE public.'.$q($table).' TO '.$q($runtime));
+ }
+ if (in_array('INSERT', $rights, true) && $db->getSchemaBuilder()->hasColumn($table, 'id')) {
+ $seq = $db->selectOne('select pg_get_serial_sequence(?,?) as name', ['public.'.$table, 'id'])->name;
+ if ($seq) {
+ $db->statement('GRANT USAGE ON SEQUENCE '.$seq.' TO '.$q($runtime));
+ }
+ }
+}
+$db->statement('REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC');
+foreach ($baseline['runtime_functions'] as $function) {
+ $db->statement('GRANT EXECUTE ON FUNCTION '.$function.' TO '.$q($runtime));
+}
+foreach (SqlPrivileges::plan($runtime)['statements'] as $sql) {
+ $db->statement($sql);
+}
+$expected = json_decode(file_get_contents(getenv('FIX_WORK').'/expected.json'), true, 64, JSON_THROW_ON_ERROR);
+$fingerprints = [];
+foreach (array_keys($expected) as $table) {
+ $rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
+ $fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR));
+}
+if ($fingerprints !== $expected) {
+ throw new RuntimeException('Recovered data differs.');
+}
+config(['database.connections.recovered_runtime' => [...$db->getConfig(), 'username' => $runtime, 'password' => getenv('FIX_RUNTIME_PASSWORD'), 'url' => null]]);
+$rt = DB::connection('recovered_runtime');
+if ($rt->table('ir_requests')->value('status') !== 'approved' || $rt->table('cb_budget_entries')->where('kind', 'reserve')->count() !== 1) {
+ throw new RuntimeException('Recovered runtime cannot consult original decision or reservation.');
+}
+if ($rt->selectOne("select has_schema_privilege(current_user,'public','CREATE') as allowed")->allowed) {
+ throw new RuntimeException('Recovery escalated runtime role.');
+}
+$receipt = $rt->table('sgc_installations')->first();
+if ($receipt->schema_signature !== Installation::signature($rt, Installation::tables())) {
+ throw new RuntimeException('Recovered schema differs.');
+}
+echo json_encode(['verified' => true, 'tables_reconciled' => count($fingerprints), 'approved_origin_reservation_versions_preserved' => true, 'runtime_read_verified' => true, 'runtime_ddl_denied' => true, 'schema_and_triggers_verified' => true, 'sql_acl_reapplied_only_to_isolated_role' => true], JSON_THROW_ON_ERROR);
diff --git a/tests/Support/sgf-corrected-update.php b/tests/Support/sgf-corrected-update.php
new file mode 100644
index 0000000..edcb67f
--- /dev/null
+++ b/tests/Support/sgf-corrected-update.php
@@ -0,0 +1,24 @@
+\n", FILE_APPEND);
+$vm['support_files'][$file] = hash_file('sha256', $variant.'/'.$file);
+file_put_contents($variant.'/manifest.json', json_encode($vm, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+$vh = hash_file('sha256', $variant.'/manifest.json');
+$vp = $deploy->plan($variant, $vh);
+$vu = $deploy->apply($variant, $vh, $vp['target_sha256'], 'Isolated code-only update', true);
+$check(hash_file('sha256', $clone.'/packages/internal-requisitions/manifest.json') === $vh, 'code-only successor installed over existing provenance');
+$deploy->rollback($vu['deployment_id'], 'Isolated successor recovery', true);
+$check(hash_file('sha256', $clone.'/packages/internal-requisitions/manifest.json') === hash_file('sha256', $bundle.'/manifest.json'), 'code-only successor rollback restores exact candidate');
+foreach ($fingerprints as $table => $expected) {
+ $rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
+ if (hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR)) !== $expected) throw new RuntimeException('Update changed '.$table);
+}
+$check(true, 'code update and rollback preserve every reproduced data table');
diff --git a/tests/Support/sgf-sourcing-controls.php b/tests/Support/sgf-sourcing-controls.php
new file mode 100644
index 0000000..fff2f04
--- /dev/null
+++ b/tests/Support/sgf-sourcing-controls.php
@@ -0,0 +1,16 @@
+make(Kernel::class)->bootstrap();
+try{
+ $db=DB::connection();if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$db->getDatabaseName()))throw new RuntimeException('Copy only');
+ $fixture=json_decode(file_get_contents($work.'/fixture.json'),true);$command=$argv[1];
+ if(in_array($command,['revoke-evaluate','restore-evaluate','revoke-propose','restore-propose','revoke-view','restore-view'])){
+ $right=str_contains($command,'evaluate')?'purchasing_sourcing.evaluate':(str_contains($command,'propose')?'purchasing_sourcing.propose':'purchasing_requisitions.view');$id=str_contains($command,'evaluate')?$fixture['user_id']:(str_contains($command,'propose')?2:3);
+ $scope=['user_id'=>$id,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$right];
+ if(str_starts_with($command,'revoke'))$db->table('permission_grants')->where($scope)->delete();else $db->table('permission_grants')->insertOrIgnore($scope+['created_at'=>now(),'updated_at'=>now()]);
+ }elseif($command==='fail-on')file_put_contents($host.'/storage/framework/sourcing-failure.fixture','isolated failure injection');
+ elseif($command==='fail-off')unlink($host.'/storage/framework/sourcing-failure.fixture');
+ else throw new RuntimeException('Unsupported fixture control');
+ echo json_encode(['ok'=>true]);
+}catch(Throwable $e){fwrite(STDERR,$e->getMessage());exit(1);}
diff --git a/tests/Support/sgf-sourcing-copy.php b/tests/Support/sgf-sourcing-copy.php
new file mode 100644
index 0000000..a40871e
--- /dev/null
+++ b/tests/Support/sgf-sourcing-copy.php
@@ -0,0 +1,51 @@
+make(Kernel::class)->bootstrap();
+try{
+ $db=DB::connection();$name=$db->selectOne('select current_database() as db')->db;
+ if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$name)||$name!==getenv('SOURCING_DB')||!str_contains(str_replace('\\','/',$host),'/private/sourcing-')||str_replace('\\','/',$app->basePath())!==$host)throw new RuntimeException('Not an isolated sourcing copy');
+ $candidate=json_decode(file_get_contents($root.'/docs/proofs/sgf-sourcing-candidate.json'),true,32,JSON_THROW_ON_ERROR);
+ if(!class_exists(Schema::class))require_once $candidate['directory'].'/integrations/sgf/sourcing/Schema.php';require_once $root.'/integrations/sgf/operational/CodeDeployment.php';
+ $deploy=new CodeDeployment($host);$checks=[];$check=function($ok,$label)use(&$checks){if(!$ok)throw new RuntimeException($label);$checks[]=$label;};
+ $fingerprint=function(string $table)use($db){return hash('sha256',json_encode($db->table($table)->orderBy('id')->get(),JSON_THROW_ON_ERROR));};
+ if(($argv[1]??'')==='setup'){
+ $baseline=['request'=>$fingerprint('ir_requests'),'budget'=>$fingerprint('cb_budget_entries'),'consumption'=>$fingerprint('cb_budget_consumptions'),'grants_count'=>$db->table('permission_grants')->count()];file_put_contents($work.'/origin.json',json_encode($baseline));
+ $armed=true;$db->listen(function($query)use(&$armed){if($armed&&str_contains(strtolower($query->sql),'create table "ss_events"')){$armed=false;throw new RuntimeException('Injected migration failure');}});
+ try{Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated failure proof');throw new RuntimeException('Expected failure');}catch(RuntimeException $e){$check($e->getMessage()==='Injected migration failure','Injected migration failure surfaced');}
+ $check(!$db->getSchemaBuilder()->hasTable('ss_suppliers')&&!$db->table('sgc_installations')->where('module','sourcing')->exists(),'Migration failure rolls back tables and catalogue');
+ $migration=Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated sourcing proof');$check($migration['prepared'],'Additive migration accepted');$check(Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated replay')['replayed'],'Migration replay is stable');
+ $old=json_decode(file_get_contents($host.'/packages/internal-requisitions/manifest.json'),true);$next=json_decode(file_get_contents($candidate['directory'].'/manifest.json'),true);
+ $current=json_decode(file_get_contents($host.'/storage/app/private/module-deployments/current.json'),true);
+ try{$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);throw new RuntimeException('Unprepared plan accepted');}catch(RuntimeException $e){$check(str_contains($e->getMessage(),'migração própria'),'Schema-changing update refuses missing migration receipt');}
+ $receipt=['from_manifest_sha256'=>$current['manifest_sha256'],'to_manifest_sha256'=>$candidate['archive']['manifest_sha256'],'host'=>$host,'schema_verified'=>Schema::compatible($db),'changes'=>[]];
+ foreach($next['support_files'] as $file=>$hash)if(($old['support_files'][$file]??null)!==$hash)$receipt['changes'][$file]=['before'=>$old['support_files'][$file]??null,'after'=>$hash];
+ file_put_contents($host.'/storage/framework/down','{}');
+ $cli=new Symfony\Component\Process\Process([PHP_BINARY,$candidate['directory'].'/scripts/sgf-sourcing-install.php','migrate',$host,Tempo\Purchasing\Delivery\Installation::target($db),$work.'/before.dump',hash_file('sha256',$work.'/before.dump'),'Isolated migration CLI',$candidate['archive']['manifest_sha256']],$host,['PG_RESTORE_BINARY'=>'C:/Program Files/PostgreSQL/17/bin/pg_restore.exe']);$cli->mustRun();
+ $cliResult=json_decode($cli->getOutput(),true,32,JSON_THROW_ON_ERROR);$check($cliResult['prepared']&&!$cliResult['activated']&&!$cliResult['rights_granted'],'Packaged migration CLI produces exact receipt without activation or grants');
+ $plan=$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);$applied=$deploy->apply($candidate['directory'],$candidate['archive']['manifest_sha256'],$plan['target_sha256'],'Isolated sourcing update',true);unlink($host.'/storage/framework/down');file_put_contents($work.'/applied.json',json_encode($applied));
+ $target=Tempo\Purchasing\Delivery\Installation::target($db);file_put_contents($host.'/config/purchasing-operational.php','true,'target'=>$target,'scopes'=>['1:1:1']],true).';');file_put_contents($host.'/config/purchasing-sourcing.php','true];');
+ $private=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json'),true);
+ $user=User::forceCreate(['name'=>'Synthetic isolated evaluator','email'=>'fixture-'.Str::uuid().'@example.invalid','password'=>$private['password'],'auth_version'=>1]);
+ $db->table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>1,'company_id'=>1]);
+ $departmentPolicy=$db->table('ir_policies')->where(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1])->first();$departmentPayload=json_decode($departmentPolicy->payload,true);$departmentPayload['members'][]=['user_id'=>$user->id,'department_ref'=>'OPER'];$db->table('ir_policies')->where('id',$departmentPolicy->id)->update(['payload'=>json_encode($departmentPayload)]); // Disposable configuration fixture only; submitted request snapshot is unchanged.
+ $grants=[1=>['purchasing_sourcing.prepare'],2=>['purchasing_sourcing.evaluate','purchasing_sourcing.propose'],$user->id=>['company.view','unit.view','purchasing_requisitions.view','purchasing_sourcing.evaluate','purchasing_sourcing.confirm']];
+ foreach($grants as $id=>$rights)foreach($rights as $right)$db->table('permission_grants')->insert(['user_id'=>$id,'tenant_id'=>1,'company_id'=>1,'unit_id'=>$right==='company.view'?null:1,'permission'=>$right,'created_at'=>now(),'updated_at'=>now()]);
+ foreach([1,2,3] as $n)$db->table('ss_suppliers')->insert(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'reference'=>'SYNTHETIC-'.$n,'payload'=>json_encode(['name'=>'Fornecedor sintético '.$n,'active'=>true,'blocked'=>false,'review_until'=>'2027-12-31','version'=>1,'synthetic_only'=>true]),'created_at'=>now()]);
+ file_put_contents($work.'/fixture.json',json_encode(['user_id'=>$user->id,'email'=>$user->email,'password'=>$private['password']]));
+ echo json_encode(['passed'=>true,'checks'=>$checks,'fixture_user_only_in_disposable_copy'=>true,'deployment'=>$applied]);
+ }elseif(($argv[1]??'')==='verify'){
+ $baseline=json_decode(file_get_contents($work.'/origin.json'),true);foreach(['request'=>'ir_requests','budget'=>'cb_budget_entries','consumption'=>'cb_budget_consumptions'] as $key=>$table)$check($baseline[$key]===$fingerprint($table),'Origin '.$key.' unchanged');
+ $check($db->table('ss_processes')->count()===1,'Exactly one sourcing process');$check($db->table('ss_commands')->count()===$db->table('ss_events')->count(),'One event per committed command');
+ $saved=$db->table('ss_processes')->first();$state=json_decode($saved->payload,true);$check($state['confirmed']!==null,'Independent award confirmed in isolated copy');
+ $before=$fingerprint('ss_events');$failed=false;try{$db->transaction(fn()=>$db->table('ss_events')->where('id',1)->update(['action'=>'tampered']));}catch(Throwable){$failed=true;}$check($failed&&$before===$fingerprint('ss_events'),'Database rejects event tampering');
+ $applied=json_decode(file_get_contents($work.'/applied.json'),true);file_put_contents($host.'/storage/framework/down','{}');$rolled=$deploy->rollback($applied['deployment_id'],'Isolated sourcing code recovery',true);
+ $check($state===json_decode($db->table('ss_processes')->first()->payload,true),'Code rollback preserves award data');
+ echo json_encode(['passed'=>true,'checks'=>$checks,'code_recovery'=>$rolled,'old_code_requires_schema_recovery'=>true]);
+ }else throw new RuntimeException('Use setup/verify');
+}catch(Throwable $e){fwrite(STDERR,get_class($e).': '.$e->getMessage()."\n");exit(1);}
diff --git a/tests/Support/sgf-sourcing-http.mjs b/tests/Support/sgf-sourcing-http.mjs
new file mode 100644
index 0000000..7b6f987
--- /dev/null
+++ b/tests/Support/sgf-sourcing-http.mjs
@@ -0,0 +1,55 @@
+import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn,spawnSync} from 'node:child_process';
+const repo=process.env.SOURCING_REPO,work=process.env.SOURCING_WORK,host=process.env.SOURCING_CLONE,base='http://127.0.0.1:'+process.env.SOURCING_PORT,php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';
+assert(/^sgf_sourcing_test_[a-f0-9]{12}$/.test(process.env.SOURCING_DB));
+const privateData=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json','utf8')),fixture=JSON.parse(fs.readFileSync(work+'/fixture.json','utf8'));
+const clients={},checks=[];
+for(const p of [...privateData.profiles,{role:'decisor',email:fixture.email}]){
+ const jar=new Map();let csrf='',token='';
+ const api=async(path,method='GET',body,status=200,override={})=>{
+ const r=await fetch(base+path,{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,'X-Purchasing-Context':'procurement.internal',...override},...(body===undefined?{}:{body:JSON.stringify(body)})});
+ for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}const text=await r.text();let d;try{d=JSON.parse(text);}catch{d={message:text.slice(0,120)};}
+ assert.equal(r.status,status,`${p.role} ${path}: ${d.message??JSON.stringify(d.errors)}`);if(d.csrf)csrf=d.csrf;if(d.context?.token)token=d.context.token;return d;
+ };
+ await api('/api/session');await api('/api/login','POST',{email:p.email,password:privateData.password});await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1,module_id:'purchasing'});clients[p.role]=api;
+}
+const read=who=>clients[who]('/api/sourcing/1');
+async function command(who,action,data,status=200){const current=await read(who);const payload={operation_key:crypto.randomUUID(),version:current.state.version,...(action==='propose'?{delivery_date:'2027-01-15',delivery_location:'Local de entrega exclusivamente sintético OPER'}:{}),...data};return {result:await clients[who]('/api/sourcing/1/'+action,'POST',payload,status),payload};}
+const prep='preparador',rev='revisor',dec='decisor',consult='consulta';
+for(const id of [1,2,3])await command(prep,'select',{supplier_id:id});
+const quote=(id,price='100.00',days=5)=>({supplier_id:id,reference:'SYNTHETIC-Q-'+id,currency:'MZN',valid_until:'2027-12-31',delivery_days:days,payment_terms:'Pagamento sintético a 30 dias',items:[{line:0,unit_price:price}]});
+for(const [id,amount] of [[1,'100.00'],[2,'110.00'],[3,'120.00']])await command(prep,'quote',{...quote(id,amount),delivery_days:'5',supplier_id:String(id)});
+const qSaved=await read(prep);assert.equal(qSaved.request.id,1);assert.equal(qSaved.request.total_minor,10000);assert(!qSaved.comparison.ready);
+for(const action of ['select','quote','evaluate','propose','confirm'])await clients[consult]('/api/sourcing/1/'+action,'POST',{},403);
+await clients[consult]('/api/sourcing/1','GET',undefined,409,{'X-SGF-Context':'bad'});await clients[consult]('/api/sourcing/1','GET',undefined,403,{'X-Purchasing-Context':'procurement.third_party'});await clients[consult]('/api/sourcing/9999999','GET',undefined,404);
+checks.push('Source request reused; three suppliers and quotes; consultation is read-only; context and direct access protected');
+const evalData=async(id,who)=>{const s=await read(who);return {supplier_id:id,quote_revision:s.state.quotes[id].revision,quality:8,history:8,payment:8,no_conflict:true,reason:'Synthetic independent evaluation for isolated test.'};};
+// Real concurrent database sessions, not just serial requests to PHP's development server.
+const initial=await read(rev),workerInput={actor_id:2,payload:{operation_key:crypto.randomUUID(),version:initial.state.version,...await evalData(1,rev)}};fs.writeFileSync(work+'/worker.json',JSON.stringify(workerInput));
+const worker=(file='worker.json')=>new Promise((resolve,reject)=>{const p=spawn(php,[repo+'/tests/Support/sgf-sourcing-worker.php',work+'/'+file],{cwd:host,env:process.env,windowsHide:true});let out='',err='';p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('exit',code=>{try{if(code!==0)throw Error(err);resolve(JSON.parse(out));}catch(e){reject(e);}});});
+const workers=await Promise.all([worker(),worker()]);assert(workers.every(w=>w.status===200),JSON.stringify(workers));assert.deepEqual(workers[0].result,workers[1].result);checks.push('Concurrent identical commands return one committed version');
+const raceVersion=(await read(rev)).state.version;
+for(const [file,id] of [['race-a.json',2],['race-b.json',3]])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:2,payload:{operation_key:crypto.randomUUID(),version:raceVersion,...await evalData(id,rev)}}));
+const racing=await Promise.all([worker('race-a.json'),worker('race-b.json')]);assert.deepEqual(racing.map(r=>r.status).sort(),[200,409]);checks.push('Concurrent distinct commands use compare-and-swap; stale version refused');
+for(const id of [1,2,3]){if(id!==1)await command(rev,'evaluate',await evalData(id,rev));await command(dec,'evaluate',await evalData(id,dec));}
+let ready=await read(rev);assert(ready.comparison.ready);assert.deepEqual(ready.comparison.top_ids,[1]);
+const proposed=await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Best eligible synthetic quote with exact comparison version.'});
+await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload);checks.push('Proposal retry is idempotent');
+const confirmData=async()=>{const d=await read(dec);return {proposal_index:d.state.proposals.length-1,comparison_hash:d.state.proposals.at(-1).comparison_hash,reason:'Independent confirmation of the preserved comparison.'};};
+// The proposer does not possess confirm; independence is also tested in the shared core.
+await command(rev,'confirm',await confirmData(),403);
+await command(prep,'quote',quote(1,'100.00',6));await command(dec,'confirm',await confirmData(),409);checks.push('Changed quote blocks stale confirmation and invalidates evaluations');
+function control(name){const r=spawnSync(php,[repo+'/tests/Support/sgf-sourcing-controls.php',name],{cwd:host,env:process.env,encoding:'utf8',windowsHide:true});assert.equal(r.status,0,r.stderr);}
+for(const who of [rev,dec])await command(who,'evaluate',await evalData(1,who));
+ready=await read(rev);await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Re-proposed using current quote revision and comparison.'});
+control('revoke-evaluate');await command(dec,'confirm',await confirmData(),409);control('restore-evaluate');
+control('revoke-propose');await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload,403);control('restore-propose');
+control('revoke-view');await clients[consult]('/api/sourcing/1','GET',undefined,403);control('restore-view');checks.push('Revocation is effective for existing sessions, replay and qualified evaluations');
+const beforeFail=await read(prep);control('fail-on');await command(prep,'quote',quote(2,'110.00',7),500);control('fail-off');const afterFail=await read(prep);assert.equal(afterFail.state.version,beforeFail.state.version);assert.equal(afterFail.history.length,beforeFail.history.length);checks.push('Injected failure after event rolls back state, event and receipt');
+const confirmVersion=(await read(dec)).state.version,confirmation=await confirmData();
+for(const file of ['confirm-a.json','confirm-b.json'])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:fixture.user_id,action:'confirm',payload:{operation_key:crypto.randomUUID(),version:confirmVersion,...confirmation}}));
+const confirmations=await Promise.all([worker('confirm-a.json'),worker('confirm-b.json')]);assert.deepEqual(confirmations.map(r=>r.status).sort(),[200,409]);
+const confirmed=await read(consult);assert(confirmed.state.confirmed);assert.equal(confirmed.state.confirmed.confirmed_by,fixture.user_id);assert.equal(confirmed.state.confirmed.proposal.proposed_by,2);checks.push('Concurrent confirmations produce one immutable independent award');
+await command(prep,'quote',quote(1),409);checks.push('Independent confirmation references exact comparison and final state is immutable');
+const origin=await clients[consult]('/api/internal-requisitions/1');assert.equal(origin.record.version,3);assert.equal(origin.record.total_minor,10000);assert.equal(origin.history.length,3);checks.push('Accepted requisition read path remains intact');
+await clients[consult]('/purchasing/sourcing/1');await clients[consult]('/purchasing/sourcing/screen.mjs');checks.push('Authenticated read-only screen and module script served through protected routes');
+console.log(JSON.stringify({passed:true,checks,request_id:1,confirmed_only_in_isolated_copy:true,account_fixture_only_in_copy:true}));
diff --git a/tests/Support/sgf-sourcing-worker.php b/tests/Support/sgf-sourcing-worker.php
new file mode 100644
index 0000000..ed7f2ba
--- /dev/null
+++ b/tests/Support/sgf-sourcing-worker.php
@@ -0,0 +1,15 @@
+make(Kernel::class)->bootstrap();
+try{
+ $db=DB::connection();if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$db->getDatabaseName()))throw new RuntimeException('Copy only');
+ $input=json_decode(file_get_contents($argv[1]),true,32,JSON_THROW_ON_ERROR);$user=User::findOrFail($input['actor_id']);
+ auth()->setUser($user);app(CompanyContext::class)->selectFor($user->id,1,1,1);
+ $request=Request::create('/api/sourcing/1/evaluate','POST');$request->headers->set('X-Purchasing-Context','procurement.internal');$app->instance('request',$request);
+ $result=(new Tempo\Purchasing\SgfSourcing\Workspace($user))->command(1,$input['action']??'evaluate',$input['payload']);
+ echo json_encode(['status'=>200,'result'=>$result]);
+}catch(Throwable $e){echo json_encode(['status'=>$e instanceof Symfony\Component\HttpKernel\Exception\HttpExceptionInterface?$e->getStatusCode():($e instanceof Tempo\Purchasing\Internal\RequisitionFailure?$e->status:500),'message'=>$e->getMessage()]);}
diff --git a/tests/Support/sgf-update-fixture.php b/tests/Support/sgf-update-fixture.php
new file mode 100644
index 0000000..d13344b
--- /dev/null
+++ b/tests/Support/sgf-update-fixture.php
@@ -0,0 +1,66 @@
+isDot() || $file->isLink() || str_starts_with($file->getFilename(), '.')) {
+ continue;
+ }
+ $target = $to.'/'.$file->getFilename();
+ if ($file->isDir()) {
+ $copy($file->getPathname(), $target, $links);
+ } elseif (! $links || ! link($file->getPathname(), $target)) {
+ copy($file->getPathname(), $target);
+ }
+ }
+ };
+ if ($native) {
+ foreach (['app', 'config', 'routes', 'packages', 'resources', 'public'] as $directory) {
+ $copy($original.'/'.$directory, $root.'/'.$directory);
+ }
+ $old = json_decode(file_get_contents(dirname(__DIR__, 2).'/docs/proofs/sgf-composition-delivery.json'), true, 32, JSON_THROW_ON_ERROR)['clone'];
+ $old = realpath($old) ?: '';
+ $temp = str_replace('\\', '/', realpath(sys_get_temp_dir()));
+ $normal = str_replace('\\', '/', $old);
+ $private = str_starts_with(strtolower($normal), strtolower($temp).'/sgf-composition-') && preg_match('/^sgf-composition-[a-f0-9]{12}$/D', basename($old));
+ // Hardlinks only to an earlier disposable dependency copy, never to the actual destination.
+ $copy(($private && is_dir($old.'/vendor')) ? $old.'/vendor' : $original.'/vendor', $root.'/vendor', $private);
+ mkdir($root.'/bootstrap/cache', 0700, true);
+ foreach (['app.php', 'providers.php'] as $file) {
+ copy($original.'/bootstrap/'.$file, $root.'/bootstrap/'.$file);
+ }
+ copy($original.'/composer.json', $root.'/composer.json');
+ copy($original.'/artisan', $root.'/artisan');
+ if (!is_dir($root.'/public')) mkdir($root.'/public', 0700, true);
+ copy($original.'/public/index.php', $root.'/public/index.php');
+ } else {
+ foreach ($manifest['host_overlays'] as $file => $values) {
+ if (! is_dir(dirname($root.'/'.$file))) {
+ mkdir(dirname($root.'/'.$file), 0700, true);
+ }copy($original.'/'.$file, $root.'/'.$file);
+ }
+ }
+ // Frontend overlays are existing host files too; preserve their exact baseline
+ // in native copies without sharing mutable assets with the real destination.
+ foreach ($manifest['host_overlays'] as $file => $values) {
+ if (!is_file($root.'/'.$file)) {
+ if (!is_dir(dirname($root.'/'.$file))) mkdir(dirname($root.'/'.$file),0700,true);
+ copy($original.'/'.$file,$root.'/'.$file);
+ }
+ }
+ foreach (['bootstrap/cache', 'packages/purchasing', 'storage/framework/views', 'storage/framework/cache', 'storage/framework/sessions', 'storage/logs'] as $directory) {
+ if (! is_dir($root.'/'.$directory)) {
+ mkdir($root.'/'.$directory, 0700, true);
+ }
+ }
+ copy($original.'/packages/purchasing/current.json',$root.'/packages/purchasing/current.json');
+ file_put_contents($root.'/storage/framework/down','{"status":503}');
+
+ return $root;
+}
diff --git a/tests/Support/warehouse-http-router.php b/tests/Support/warehouse-http-router.php
new file mode 100644
index 0000000..0ce8f5b
--- /dev/null
+++ b/tests/Support/warehouse-http-router.php
@@ -0,0 +1,62 @@
+synthetic-private-body-do-not-log';
+ exit;
+}
+if ($scenario === 'json_error') {
+ echo 'synthetic-private-body-do-not-log';
+ exit;
+}
+if ($scenario === 'oversized') {
+ echo str_repeat('x', 16384);
+ exit;
+}
+if ($scenario === 'slow') {
+ usleep(1200000);
+}
+$cursor = $_GET['cursor'] ?? null;
+$page = $scenario === 'paged' && $cursor !== null
+ ? inboxPage([inboxEvent(2, 'receive', 'sgc:7:warehouse-event:1')], 'cursor-two')
+ : inboxPage([inboxEvent()], 'cursor-one', $scenario === 'paged');
+if ($scenario === 'wrong_tenant') {
+ $page['items'][0]['tenant_id'] = 8;
+ $page['items'][0] = inboxSign($page['items'][0]);
+}
+if ($scenario === 'bad_hash') {
+ $page['items'][0]['lines'][0]['quantity_milli'] = 5000;
+}
+$page['fixture_query'] = ['cursor' => $cursor, 'limit' => $_GET['limit'] ?? null];
+echo json_encode($page, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/tests/Support/warehouse-return-worker.php b/tests/Support/warehouse-return-worker.php
new file mode 100644
index 0000000..0a51f39
--- /dev/null
+++ b/tests/Support/warehouse-return-worker.php
@@ -0,0 +1,11 @@
+make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS db,current_user AS role,current_setting('cluster_name') AS cluster,inet_server_port() AS port");
+if(!preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->db,$m)||$i->role!=='stock_receipt_qa_'.$m[1]||(int)$i->port!==55439||$i->cluster!=='sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'))throw new RuntimeException('Wrong worker QA database');
+$p=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);$db->select("SELECT set_config('application_name',?,false)",[$p['application']]);$db->statement("SET lock_timeout='8s'");
+$actor=App\Models\User::findOrFail($p['actor']);auth()->guard()->setUser($actor);config(['stock_returns.enabled'=>true]);
+$action=$p['action']??'confirm';if(!in_array($action,['confirm','withdraw','reject'],true))throw new RuntimeException('Invalid worker action');
+$input=['version'=>1,'reason'=>'Synthetic concurrent native warehouse decision.']+($action==='confirm'?['received_in_warehouse'=>true]:[]);
+try{if($p['http']??false){$request=Illuminate\Http\Request::create('/api/stock-returns/'.$p['proposal'].'/'.$action,'POST',$input,[],[],['HTTP_ACCEPT'=>'application/json','HTTP_X_REQUESTED_WITH'=>'XMLHttpRequest']);$kernel=$app->make(Illuminate\Contracts\Http\Kernel::class);$response=$kernel->handle($request);$out=['status'=>$response->getStatusCode(),'result'=>json_decode($response->getContent(),true)];$kernel->terminate($request,$response);}else{$flow=app(App\Services\WarehouseReturns::class);$result=$action==='confirm'?$flow->confirm($actor,$p['proposal'],$input):$flow->close($actor,$p['proposal'],$input,$action==='withdraw'?'withdrawn':'rejected');$out=['status'=>200,'result'=>$result];}}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$out=['status'=>$e->getStatusCode()];}
+file_put_contents($file.'.result',json_encode($out,JSON_THROW_ON_ERROR));
diff --git a/tests/Unit/WarehouseHttpSourceTest.php b/tests/Unit/WarehouseHttpSourceTest.php
new file mode 100644
index 0000000..c2ddb3f
--- /dev/null
+++ b/tests/Unit/WarehouseHttpSourceTest.php
@@ -0,0 +1,151 @@
+ '1'], timeout: null);
+ $server->start();
+ register_shutdown_function(fn () => $server->stop());
+ $deadline = microtime(true) + 5;
+ do {
+ $probe = @stream_socket_client('tcp://'.$address, $errno, $error, 0.1);
+ if (is_resource($probe)) {
+ fclose($probe);
+
+ return $fixture = ['base' => 'http://'.$address, 'server' => $server];
+ }
+ usleep(20000);
+ } while ($server->isRunning() && microtime(true) < $deadline);
+ throw new RuntimeException('Servidor descartável HTTP indisponível.');
+}
+
+function warehouseRemote(string $scenario = 'ok', ?Closure $credentials = null, int $timeout = 5000, int $maxBytes = 8388608): WarehouseHttpSource
+{
+ return new WarehouseHttpSource(warehouseHttpFixture()['base'].'/'.$scenario, 7,
+ $credentials ?? fn () => 'SGC_TEST_SESSION=synthetic-authorized', allowLoopbackHttp: true,
+ timeoutMs: $timeout, connectTimeoutMs: min(1000, $timeout), maxResponseBytes: $maxBytes);
+}
+
+test('HTTP source fixes the endpoint safely encodes query and obtains credentials for each read', function () {
+ $calls = 0;
+ $source = warehouseRemote(credentials: function () use (&$calls): string {
+ $calls++;
+
+ return 'SGC_TEST_SESSION=synthetic-authorized';
+ });
+ $cursor = 'opaque&tenant_id=8#? +/=';
+ $page = $source($cursor, 7);
+ expect($page['schema'])->toBe('sgc.warehouse.fulfilment-feed.v1')
+ ->and($page['fixture_query'])->toBe(['cursor' => $cursor, 'limit' => '7']);
+ $source(null, 1);
+ expect($calls)->toBe(2)->and(print_r($source, true))->not->toContain('synthetic-authorized');
+});
+
+test('real HTTP pages can be received durably in bounded runs without a stock engine', function () {
+ $inbox = new WarehouseInbox(inboxDatabase(), 'synthetic-http-source', 7, 99, 'reader');
+ $pull = new WarehousePull($inbox);
+ $source = warehouseRemote('paged');
+ expect($pull->run($source, 1, 1))->toMatchArray(['inserted' => 1, 'pages' => 1, 'has_more' => true]);
+ expect($pull->run($source, 5, 1))->toMatchArray(['inserted' => 1, 'pages' => 1, 'has_more' => false])
+ ->and($inbox->checkpoint()['last_event_id'])->toBe(2)
+ ->and($inbox->fact('sgc:7:warehouse-event:2')['delivery_event_key'])->toBe('sgc:7:warehouse-event:1');
+});
+
+test('source refuses insecure or ambiguous origins and invalid parameters before credential retrieval', function () {
+ $credentials = fn () => 'synthetic-private-secret';
+ foreach (['http://example.invalid', 'http://localhost:8005', 'file:///tmp/source', 'https://user:synthetic-private-secret@example.invalid', 'https://example.invalid?token=synthetic-private-secret', 'https://example.invalid#token', 'https://example.invalid/../host', 'https://example.invalid/%2e%2e/host', 'https://example.invalid/a\\b', "https://example.invalid/\nheader"] as $url) {
+ expect(fn () => new WarehouseHttpSource($url, 7, $credentials, allowLoopbackHttp: true))->toThrow(InvalidArgumentException::class);
+ }
+ expect(fn () => new WarehouseHttpSource('http://127.0.0.1:8005', 7, $credentials))->toThrow(InvalidArgumentException::class);
+ $calls = 0;
+ $source = warehouseRemote(credentials: function () use (&$calls): string {
+ $calls++;
+
+ return 'SGC_TEST_SESSION=synthetic-authorized';
+ });
+ foreach ([[null, 0], [null, 101], ['', 1], [str_repeat('c', 4097), 1]] as [$cursor, $limit]) {
+ expect(fn () => $source($cursor, $limit))->toThrow(InvalidArgumentException::class);
+ }
+ expect($calls)->toBe(0);
+});
+
+test('expired sessions forbidden scopes and redirects return sanitised diagnostics', function (string $scenario, string $category, ?int $status, bool $retryable) {
+ try {
+ warehouseRemote($scenario)(null, 1);
+ $this->fail('Era esperado erro de transporte.');
+ } catch (WarehouseTransportFailure $e) {
+ expect($e->category)->toBe($category)->and($e->httpStatus)->toBe($status)->and($e->retryable())->toBe($retryable)
+ ->and($e->getMessage())->not->toContain('synthetic-private')->not->toContain('SGC_TEST_SESSION')
+ ->and($e->getPrevious())->toBeNull();
+ }
+})->with([
+ ['status_401', 'authentication', 401, false], ['status_419', 'authentication', 419, false],
+ ['status_403', 'authorization', 403, false], ['redirect', 'redirect', 302, false],
+ ['status_409', 'cursor', 409, false], ['status_422', 'cursor', 422, false],
+ ['status_429', 'remote_unavailable', 429, true], ['status_503', 'remote_unavailable', 503, true],
+ ['status_404', 'http', 404, false],
+]);
+
+test('invalid or altered responses never advance the receiving checkpoint', function (string $scenario) {
+ $inbox = new WarehouseInbox(inboxDatabase(), 'synthetic-http-source', 7, 99, 'reader');
+ expect(fn () => (new WarehousePull($inbox))->run(warehouseRemote($scenario)))->toThrow(WarehouseTransportFailure::class)
+ ->and($inbox->checkpoint())->toBe(['version' => 0, 'cursor' => null, 'last_event_id' => 0])
+ ->and($inbox->fact('sgc:7:warehouse-event:1'))->toBeNull();
+})->with(['html', 'json_error', 'wrong_tenant', 'bad_hash']);
+
+test('credential failures and header injection expose no supplied secrets', function () {
+ foreach ([fn () => throw new RuntimeException('synthetic-private-secret'), fn () => "session=synthetic-private-secret\r\nOther: injected", fn () => '', fn () => ['invalid' => 'synthetic-private-secret']] as $provider) {
+ try {
+ warehouseRemote(credentials: $provider)(null, 1);
+ $this->fail('Era esperada recusa da credencial.');
+ } catch (WarehouseTransportFailure $e) {
+ expect($e->category)->toBe('credentials')->and($e->getMessage())->not->toContain('synthetic-private-secret')
+ ->and($e->getPrevious())->toBeNull();
+ }
+ }
+ expect(fn () => warehouseRemote(credentials: fn () => 'SGC_TEST_SESSION=expired')(null, 1))->toThrow(WarehouseTransportFailure::class);
+});
+
+test('connection refusal is diagnosed without losing the last receiving checkpoint', function () {
+ $socket = stream_socket_server('tcp://127.0.0.1:0');
+ $address = stream_socket_get_name($socket, false);
+ fclose($socket);
+ $source = new WarehouseHttpSource('http://'.$address, 7, fn () => 'SGC_TEST_SESSION=synthetic-authorized', allowLoopbackHttp: true, timeoutMs: 1000, connectTimeoutMs: 200);
+ $inbox = new WarehouseInbox(inboxDatabase(), 'synthetic-http-source', 7, 99, 'reader');
+ try {
+ (new WarehousePull($inbox))->run($source);
+ $this->fail('Era esperada recusa da ligação.');
+ } catch (WarehouseTransportFailure $e) {
+ expect($e->category)->toBe('connection')->and($e->retryable())->toBeTrue()
+ ->and($inbox->checkpoint()['version'])->toBe(0);
+ }
+});
+
+
+test('oversized responses and timeouts stop the HTTP read without committing a page', function () {
+ foreach ([warehouseRemote('oversized', maxBytes: 1024), warehouseRemote('slow', timeout: 200)] as $source) {
+ try {
+ $source(null, 1);
+ $this->fail('Era esperado limite de transporte.');
+ } catch (WarehouseTransportFailure $e) {
+ expect(in_array($e->category, ['response_size', 'timeout'], true))->toBeTrue();
+ }
+ }
+});
diff --git a/tests/Unit/WarehouseInboxTest.php b/tests/Unit/WarehouseInboxTest.php
new file mode 100644
index 0000000..d893d11
--- /dev/null
+++ b/tests/Unit/WarehouseInboxTest.php
@@ -0,0 +1,181 @@
+db = inboxDatabase();
+ $this->inbox = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'warehouse-reader');
+});
+
+test('receiver persists original facts and checkpoint together and safely acknowledges an exact retry', function () {
+ expect($this->inbox->checkpoint())->toBe(['version' => 0, 'cursor' => null, 'last_event_id' => 0]);
+ $delivery = inboxEvent();
+ $receipt = inboxEvent(2, 'receive', $delivery['idempotency_key']);
+ $page = inboxPage([$delivery, $receipt]);
+ expect($this->inbox->accept($page, 0, null))->toMatchArray(['inserted' => 2, 'replayed' => false, 'version' => 1])
+ ->and($this->inbox->fact($delivery['idempotency_key']))->toBe($delivery)
+ ->and($this->inbox->fact($receipt['idempotency_key']))->toBe($receipt)
+ ->and($this->inbox->checkpoint())->toBe(['version' => 1, 'cursor' => $page['next_cursor'], 'last_event_id' => 2]);
+ expect($this->inbox->accept($page, 0, null))->toMatchArray(['inserted' => 0, 'replayed' => true, 'version' => 1])
+ ->and((int) $this->db->query('SELECT COUNT(*) FROM sgc_exchange_facts')->fetchColumn())->toBe(2);
+ $restarted = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'warehouse-reader');
+ expect($restarted->checkpoint())->toBe($this->inbox->checkpoint());
+});
+
+test('receiver rolls back an entire page and cursor when a later receipt lacks its delivery', function () {
+ $page = inboxPage([inboxEvent(), inboxEvent(3, 'receive', 'sgc:7:warehouse-event:2')]);
+ expect(fn () => $this->inbox->accept($page, 0, null))->toThrow(RuntimeException::class)
+ ->and($this->inbox->checkpoint()['version'])->toBe(0)
+ ->and($this->inbox->fact('sgc:7:warehouse-event:1'))->toBeNull();
+ $fixed = inboxPage([inboxEvent(), inboxEvent(2), inboxEvent(3, 'receive', 'sgc:7:warehouse-event:2')]);
+ expect($this->inbox->accept($fixed, 0, null)['inserted'])->toBe(3);
+});
+
+test('two readers cannot advance the same checkpoint with different pages', function () {
+ $second = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'warehouse-reader');
+ $before = $second->checkpoint();
+ $this->inbox->accept(inboxPage([inboxEvent()], 'cursor-a'), 0, null);
+ expect(fn () => $second->accept(inboxPage([inboxEvent(2)], 'cursor-b'), $before['version'], $before['cursor']))->toThrow(RuntimeException::class)
+ ->and($this->inbox->checkpoint()['cursor'])->toBe('cursor-a')
+ ->and($this->inbox->fact('sgc:7:warehouse-event:2'))->toBeNull();
+});
+
+test('receiver refuses stale pages mismatched request cursors and conflicting event identities', function () {
+ $this->inbox->accept(inboxPage([inboxEvent()]), 0, null);
+ expect(fn () => $this->inbox->accept(inboxPage([inboxEvent(2)]), 1, 'another-cursor'))->toThrow(RuntimeException::class)
+ ->and(fn () => $this->inbox->accept(inboxPage([inboxEvent()]), 1, 'synthetic-opaque-cursor'))->toThrow(RuntimeException::class);
+ $otherStream = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'another-reader');
+ $conflict = inboxEvent();
+ $conflict['lines'][0]['quantity_milli'] = 2500;
+ expect(fn () => $otherStream->accept(inboxPage([inboxSign($conflict)]), 0, null))->toThrow(RuntimeException::class)
+ ->and($otherStream->checkpoint()['version'])->toBe(0)
+ ->and($this->inbox->fact('sgc:7:warehouse-event:1')['lines'][0]['quantity_milli'])->toBe(1500);
+});
+
+test('origins destination companies and consumer checkpoints remain separate', function () {
+ $page = inboxPage([inboxEvent()]);
+ $this->inbox->accept($page, 0, null);
+ foreach ([['synthetic-sgc-b', 7, 99], ['synthetic-sgc-a', 7, 100], ['synthetic-sgc-a', 8, 99]] as [$origin, $tenant, $destination]) {
+ $other = new WarehouseInbox($this->db, $origin, $tenant, $destination, 'warehouse-reader');
+ expect($other->fact('sgc:7:warehouse-event:1'))->toBeNull()->and($other->checkpoint()['version'])->toBe(0);
+ }
+ $otherStream = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'another-reader');
+ expect($otherStream->accept($page, 0, null)['inserted'])->toBe(0)
+ ->and($otherStream->checkpoint()['version'])->toBe(1);
+});
+
+test('receipt must match the exact source request and cannot be acknowledged twice', function () {
+ $delivery = inboxEvent();
+ $this->inbox->accept(inboxPage([$delivery], 'cursor-1'), 0, null);
+ $receipt = inboxEvent(2, 'receive', $delivery['idempotency_key']);
+ $receipt['request']['destination'] = 'Outro destino';
+ expect(fn () => $this->inbox->accept(inboxPage([inboxSign($receipt)]), 1, 'cursor-1'))->toThrow(RuntimeException::class);
+ $this->inbox->accept(inboxPage([inboxEvent(2, 'receive', $delivery['idempotency_key'])], 'cursor-2'), 1, 'cursor-1');
+ expect(fn () => $this->inbox->accept(inboxPage([inboxEvent(3, 'receive', $delivery['idempotency_key'])]), 2, 'cursor-2'))->toThrow(RuntimeException::class)
+ ->and($this->inbox->checkpoint()['version'])->toBe(2);
+});
+
+test('empty polling pages remain resumable and do not invent a physical fact', function () {
+ $result = $this->inbox->accept(inboxPage([], 'empty-cursor'), 0, null);
+ expect($result['inserted'])->toBe(0)->and($result['has_more'])->toBeFalse()
+ ->and($this->inbox->checkpoint())->toBe(['version' => 1, 'cursor' => 'empty-cursor', 'last_event_id' => 0]);
+ expect($this->inbox->accept(inboxPage([inboxEvent()]), 1, 'empty-cursor')['inserted'])->toBe(1);
+});
+
+test('malformed or altered feed data is refused before any receiving write', function () {
+ $cases = [];
+ foreach (['tenant_id' => 8, 'event_id' => '1', 'event_version' => 0, 'action' => 'authorize', 'occurred_at' => '2026-02-30T10:00:00+00:00', 'idempotency_key' => 'forged'] as $key => $value) {
+ $event = inboxEvent();
+ $event[$key] = $value;
+ $cases[] = inboxPage([inboxSign($event)]);
+ }
+ $event = inboxEvent();
+ $event['lines'][0]['quantity_milli'] = -1000;
+ $cases[] = inboxPage([inboxSign($event)]);
+ $event = inboxEvent();
+ $event['lines'][0]['quantity_milli'] = 1001;
+ $cases[] = inboxPage([$event]); // hash was not updated
+ $event = inboxEvent();
+ $event['lines'][0]['use'] = 'equipment';
+ $cases[] = inboxPage([inboxSign($event)]);
+ $cases[] = inboxPage([inboxEvent(2), inboxEvent()]);
+ $cases[] = inboxPage([inboxEvent(), inboxEvent()]);
+ $cases[] = inboxPage([], 'cursor', true);
+ $cases[] = ['schema' => 'unknown', 'items' => [], 'next_cursor' => 'cursor', 'has_more' => false];
+ foreach ($cases as $page) {
+ expect(fn () => $this->inbox->accept($page, 0, null))->toThrow(InvalidArgumentException::class);
+ }
+ expect((int) $this->db->query('SELECT COUNT(*) FROM sgc_exchange_streams')->fetchColumn())->toBe(0)
+ ->and((int) $this->db->query('SELECT COUNT(*) FROM sgc_exchange_facts')->fetchColumn())->toBe(0);
+});
+
+test('new compatible metadata is preserved and incompatible schemas are rejected', function () {
+ $event = inboxEvent();
+ $event['extra_metadata'] = ['reference' => 'Origem adicional sintética'];
+ $event = inboxSign($event);
+ expect($this->inbox->accept(inboxPage([$event]), 0, null)['inserted'])->toBe(1)
+ ->and($this->inbox->fact($event['idempotency_key'])['extra_metadata'])->toBe($event['extra_metadata']);
+ $bad = inboxPage([]);
+ $bad['schema'] = 'sgc.warehouse.fulfilment-feed.v2';
+ expect(fn () => WarehouseFeed::validate($bad, 7))->toThrow(InvalidArgumentException::class);
+});
+
+test('receiver never installs schema implicitly and refuses an externally owned transaction', function () {
+ $empty = new PDO('sqlite::memory:');
+ $inbox = new WarehouseInbox($empty, 'origin', 7, 99, 'reader');
+ expect((int) $empty->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")->fetchColumn())->toBe(0);
+ $this->db->beginTransaction();
+ try {
+ expect(fn () => $this->inbox->accept(inboxPage([inboxEvent()]), 0, null))->toThrow(InvalidArgumentException::class)
+ ->and($this->db->inTransaction())->toBeTrue();
+ } finally {
+ $this->db->rollBack();
+ }
+});
+
+test('bounded pull continues from the persisted cursor on the next execution', function () {
+ $seen = [];
+ $fetch = function (?string $cursor, int $size) use (&$seen): array {
+ $seen[] = [$cursor, $size];
+
+ return $cursor === null ? inboxPage([inboxEvent()], 'cursor-one', true)
+ : inboxPage([inboxEvent(2, 'receive', 'sgc:7:warehouse-event:1')], 'cursor-two');
+ };
+ $pull = new WarehousePull($this->inbox);
+ expect($pull->run($fetch, 1, 1))->toMatchArray(['pages' => 1, 'inserted' => 1, 'has_more' => true]);
+ expect($pull->run($fetch, 1, 1))->toMatchArray(['pages' => 1, 'inserted' => 1, 'has_more' => false])
+ ->and($seen)->toBe([[null, 1], ['cursor-one', 1]])
+ ->and($this->inbox->checkpoint()['last_event_id'])->toBe(2);
+});
+
+test('transport failure preserves already accepted pages and later resumes the failed page', function () {
+ $fetch = fn (?string $cursor, int $size) => $cursor === null ? inboxPage([inboxEvent()], 'cursor-one', true) : throw new RuntimeException('Indisponibilidade sintética.');
+ $pull = new WarehousePull($this->inbox);
+ expect(fn () => $pull->run($fetch, 5))->toThrow(RuntimeException::class)
+ ->and($this->inbox->checkpoint())->toBe(['version' => 1, 'cursor' => 'cursor-one', 'last_event_id' => 1]);
+ $retry = function (?string $cursor, int $size): array {
+ expect($cursor)->toBe('cursor-one');
+
+ return inboxPage([inboxEvent(2, 'receive', 'sgc:7:warehouse-event:1')]);
+ };
+ expect($pull->run($retry, 5)['inserted'])->toBe(1);
+});
+
+test('pull stops at the actual end and rejects invalid bounds before calling a transport', function () {
+ $calls = 0;
+ $fetch = function (?string $cursor, int $size) use (&$calls): array {
+ $calls++;
+
+ return inboxPage([]);
+ };
+ $pull = new WarehousePull($this->inbox);
+ expect($pull->run($fetch, 100)['pages'])->toBe(1)->and($calls)->toBe(1);
+ foreach ([[0, 1], [101, 1], [1, 0], [1, 101]] as [$pages, $size]) {
+ expect(fn () => $pull->run($fetch, $pages, $size))->toThrow(InvalidArgumentException::class);
+ }
+ expect($calls)->toBe(1);
+});