diff --git a/.env.example b/.env.example index 561ba5a..566e4ec 100644 --- a/.env.example +++ b/.env.example @@ -71,3 +71,6 @@ COMMON_PAYABLES_ENABLED=false COMMON_PAYABLES_HISTORY_ENABLED=false COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED=false PAYABLE_SOURCE_INSTANCE= + +# Adesão voluntária MFA, só depois da migração e validação da instalação. +SGC_TWO_FACTOR_ENABLED=false diff --git a/app/Console/Commands/ReplenishmentHealth.php b/app/Console/Commands/ReplenishmentHealth.php new file mode 100644 index 0000000..831ab40 --- /dev/null +++ b/app/Console/Commands/ReplenishmentHealth.php @@ -0,0 +1,37 @@ +health(); + } catch (Throwable $error) { + report($error); + $result = ['checked_at' => now()->toIso8601String(), 'ok' => false, 'reasons' => ['unavailable']]; + } + if ($this->option('json')) { + $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } else { + $this->line($result['ok'] ? 'Reposição: verificação técnica sem pendências observadas.' : 'Reposição: verificação técnica requer atenção.'); + $labels = ['not_installed' => 'Reposição durável não instalada nesta composição.', 'not_recent' => 'Sem passagem recente confirmada.', + 'last_run_not_successful' => 'Última passagem sem sucesso confirmado.', 'pending_signals' => 'Existem sinais duráveis por processar.', + 'failed_work' => 'Existem trabalhos a recuperar.', 'unbound_observation' => 'Passagem sem associação confirmada a esta instalação/base.', 'unavailable' => 'Não foi possível consultar. Verifique o registo técnico.']; + foreach ($result['reasons'] as $reason) { + $this->line($labels[$reason]); + } + } + + return $result['ok'] ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Console/Commands/ReplenishmentTick.php b/app/Console/Commands/ReplenishmentTick.php index c8ddcc5..189b389 100644 --- a/app/Console/Commands/ReplenishmentTick.php +++ b/app/Console/Commands/ReplenishmentTick.php @@ -2,6 +2,8 @@ namespace App\Console\Commands; +use App\Services\ReplenishmentHeartbeat; +use App\Services\ReplenishmentProcessLock; use App\Services\ReplenishmentQueue; use Illuminate\Console\Command; @@ -24,10 +26,30 @@ class ReplenishmentTick extends Command return self::INVALID; } - $queue->discover(); - $result = $queue->drain((int) $limit); - $this->info('Reposição: '.$result['processed'].' trabalhos conferidos; '.$result['failed'].' a recuperar.'); + $started = now()->toIso8601String(); + $exitCode = self::FAILURE; + $lock = app(ReplenishmentProcessLock::class); + if (! $lock->acquire()) { + $this->info('Verificação periódica já em execução; aguarde a próxima passagem.'); - return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE; + return self::FAILURE; + } + try { + $queue->discover(); + $result = $queue->drain((int) $limit); + $this->info('Reposição: '.$result['processed'].' trabalhos conferidos; '.$result['failed'].' a recuperar.'); + $exitCode = $result['failed'] === 0 ? self::SUCCESS : self::FAILURE; + + return $exitCode; + } finally { + try { + app(ReplenishmentHeartbeat::class)->record($started, $exitCode); + } catch (\Throwable $error) { + report($error); + $this->warn('Não foi possível actualizar o acompanhamento técnico desta execução.'); + } finally { + $lock->release(); + } + } } } diff --git a/app/Http/Controllers/AutomaticPurchaseController.php b/app/Http/Controllers/AutomaticPurchaseController.php index 6641df6..b3ea8e2 100644 --- a/app/Http/Controllers/AutomaticPurchaseController.php +++ b/app/Http/Controllers/AutomaticPurchaseController.php @@ -14,6 +14,7 @@ use Illuminate\Http\RedirectResponse; 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; @@ -26,7 +27,10 @@ class AutomaticPurchaseController extends Controller $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']); + $filters = $request->validate(['rule_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'auto_rule')], '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']); + $focus = isset($filters['rule_id']) ? SupplyRecord::query()->where('kind', 'auto_rule')->findOrFail((int) $filters['rule_id']) : null; + $rules = $planning->latest('auto_rule')->when($focus !== null, fn ($query) => $query->where('record_key', $focus->record_key)); + $latestFocus = $focus === null ? null : (clone $rules)->first(); $needs = $planning->latest('auto_need'); if (($filters['need_state'] ?? 'all') !== 'all') { $needs->where('state', $filters['need_state']); @@ -36,7 +40,8 @@ class AutomaticPurchaseController extends Controller $response = Inertia::render('stock/replenishment', [ 'ready' => $ready, 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'), 'filters' => ['need_state' => $filters['need_state'] ?? 'all'], - 'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString() : null, + 'focusRuleId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $latestFocus?->id !== $focus->id, + 'rules' => $ready ? $rules->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString()->through(fn (SupplyRecord $rule) => [...$rule->toArray(), 'canApprove' => $service->canReview($actor, $rule), 'canSuspend' => $service->canReview($actor, $rule, 'suspend')]) : 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), diff --git a/app/Http/Controllers/GeneralLedgerController.php b/app/Http/Controllers/GeneralLedgerController.php new file mode 100644 index 0000000..e076a21 --- /dev/null +++ b/app/Http/Controllers/GeneralLedgerController.php @@ -0,0 +1,69 @@ +validate(['from_on' => 'required|date_format:Y-m-d', 'to_on' => 'required|date_format:Y-m-d|after_or_equal:from_on', 'account_id' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100']); + abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422); + try { + $data = $ledger->report($this->actor($r), $kind, $d); + } catch (\InvalidArgumentException $e) { + abort(422, $e->getMessage()); + } catch (\DomainException $e) { + abort(403, $e->getMessage()); + } + if ($r->is('api/*')) { + return response()->json($data)->header('Cache-Control', 'no-store'); + } + + return response(ReportsWorkspace::render($data, ['host' => 'SGC autónomo', 'return_url' => '/general-ledger/cycle']))->header('Cache-Control', 'no-store'); + } + + private function actor(Request $r): User + { + $user = $r->user(); + abort_unless($user instanceof User, 403); + + return $user; + } + + public function page(Request $r, GeneralLedger $ledger): Response + { + $d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000']); + abort_if(array_diff(array_keys($r->query()), ['page']) !== [], 422); + $data = $ledger->workspace($this->actor($r), (int) ($d['page'] ?? 1)); + + return response(CycleWorkspace::render($data, ['host' => 'SGC autónomo', 'csrf' => csrf_token(), 'financial_url' => '/supplier-payments', 'return_url' => '/obrigacoes-fornecedores', 'page_url' => '/general-ledger/cycle']))->header('Cache-Control', 'no-store'); + } + + public function command(Request $r, string $action, GeneralLedger $ledger): JsonResponse + { + abort_unless(config('general-ledger.cycle_enabled') === true, 403); + try { + return response()->json($ledger->engine($this->actor($r))->command($action, $r->all(), (string) $r->header('Idempotency-Key')))->header('Cache-Control', 'no-store'); + } catch (\InvalidArgumentException $e) { + abort(422, $e->getMessage()); + } catch (\DomainException $e) { + abort(409, $e->getMessage()); + } + } + + public function index(Request $r, GeneralLedger $ledger): JsonResponse + { + $d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000']); + abort_if(array_diff(array_keys($r->query()), ['page']) !== [], 422); + + return response()->json($ledger->workspace($this->actor($r), (int) ($d['page'] ?? 1)))->header('Cache-Control', 'no-store'); + } +} diff --git a/app/Http/Controllers/GoodsReceiptController.php b/app/Http/Controllers/GoodsReceiptController.php index 3d5f9cd..515cc22 100644 --- a/app/Http/Controllers/GoodsReceiptController.php +++ b/app/Http/Controllers/GoodsReceiptController.php @@ -5,12 +5,15 @@ namespace App\Http\Controllers; use App\Http\Requests\ConfirmReceiptRequest; use App\Models\GoodsReceipt; use App\Models\PurchaseOrder; +use App\Models\ReceiptDocument; use App\Models\Requisition; use App\Models\StockItem; use App\Models\StockMovement; +use App\Models\User; use App\Models\Warehouse; use App\Services\InventoryLots; use App\Services\InventoryReversals; +use App\Services\ReceiptDocuments; use App\Services\ReceiptWorkflow; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; @@ -23,13 +26,21 @@ use Inertia\Response; class GoodsReceiptController extends Controller { + private function actor(Request $request): User + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + + return $actor; + } + /** @return Builder */ private function visibleOrders(Request $request): Builder { Gate::authorize('viewAny', GoodsReceipt::class); $orders = PurchaseOrder::query(); - if ($request->user()->departmentScoped()) { - $orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id')); + if ($this->actor($request)->departmentScoped()) { + $orders->whereIn('requisition_id', Requisition::query()->visibleTo($this->actor($request))->select('id')); } return $orders; @@ -65,7 +76,7 @@ class GoodsReceiptController extends Controller public function store(ConfirmReceiptRequest $request, PurchaseOrder $order, ReceiptWorkflow $workflow): JsonResponse|RedirectResponse { - $receipt = $workflow->confirm($request->user(), $order, $request->validated()); + $receipt = $workflow->confirm($this->actor($request), $order, $request->validated()); if ($request->expectsJson()) { return response()->json(['id' => $receipt->id, 'number' => $receipt->number, 'created' => $receipt->wasRecentlyCreated, 'status' => $receipt->status], $receipt->wasRecentlyCreated ? 201 : 200); } @@ -73,14 +84,37 @@ class GoodsReceiptController extends Controller return to_route('receipts.show', $receipt); } - public function show(GoodsReceipt $receipt): Response + public function show(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): Response { Gate::authorize('view', $receipt); + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $ready = $documents->ready(); + $order = PurchaseOrder::query()->findOrFail($receipt->purchase_order_id); + $source = Requisition::query()->visibleTo($actor)->whereKey($order->requisition_id)->first(['id']); + Inertia::encryptHistory(); return Inertia::render('receipts/show', [ + 'documentsReady' => $ready, + 'purchaseProcessHref' => $source === null ? null : '/requisicoes/'.$source->id.'/processo', + 'canAddDocuments' => $ready && $documents->canAdd($actor, $receipt), + 'documents' => $ready && $documents->canRead($actor, $receipt) ? ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->orderBy('id')->get(['id', 'document_type', 'original_name', 'size', 'sha256', 'uploaded_by', 'created_at']) : [], 'correctedReceiptItems' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all())->map(fn ($r) => $r->data['reference']), 'receipt' => $receipt->load('items'), 'movements' => StockMovement::query()->whereIn('goods_receipt_item_id', $receipt->items()->select('id'))->orderBy('id')->get(), ]); } + + public function print(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): \Illuminate\Http\Response + { + Gate::authorize('view', $receipt); + $actor = $this->actor($request); + + return response()->view('receipts.print', [ + 'receipt' => $receipt->load('items'), + 'corrections' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all()), + 'documents' => $documents->ready() && $documents->canRead($actor, $receipt) + ? ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->orderBy('id')->get(['document_type', 'original_name', 'sha256', 'created_at']) : collect(), + ])->header('Cache-Control', 'no-store, private')->header('X-Content-Type-Options', 'nosniff'); + } } diff --git a/app/Http/Controllers/InventoryDispositionController.php b/app/Http/Controllers/InventoryDispositionController.php index 842466d..55e6498 100644 --- a/app/Http/Controllers/InventoryDispositionController.php +++ b/app/Http/Controllers/InventoryDispositionController.php @@ -3,24 +3,24 @@ namespace App\Http\Controllers; use App\Models\GoodsReceipt; -use App\Models\GoodsReceiptItem; use App\Models\StockItem; use App\Models\SupplyRecord; use App\Models\User; use App\Models\Warehouse; +use App\Services\InventoryCorrectionPage; use App\Services\InventoryDispositions; use App\Services\InventoryLots; use App\Services\InventoryReversals; -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 Inertia\Response; +use Symfony\Component\HttpFoundation\Response; class InventoryDispositionController extends Controller { - public function index(Request $request, InventoryDispositions $workflow): Response|RedirectResponse + public function index(Request $request, InventoryDispositions $workflow): Response { $actor = $request->user('web'); abort_unless($actor instanceof User, 403); @@ -33,12 +33,25 @@ class InventoryDispositionController extends Controller return redirect('/stock/estornos'); } abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403); - $ready = Schema::hasTable('supply_records'); - $records = $ready ? app(SupplyPlanning::class)->latest('stock_event')->latest('id')->limit(100)->get() : collect(); + + return DB::transaction(function () use ($request, $workflow, $actor, $capabilities, $canViewReversals) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + + return $this->page($request, $workflow, $actor, $capabilities, $canViewReversals); + }); + } + + /** @param array $capabilities */ + private function page(Request $request, InventoryDispositions $workflow, User $actor, array $capabilities, bool $canViewReversals): Response + { + $page = app(InventoryCorrectionPage::class)->read($request, $actor, 'stock_event'); + ['ready' => $ready, 'focus' => $focus, 'records' => $records] = $page; $receipts = []; $lots = []; if ($ready && $capabilities['prepare']) { - foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) { + foreach (app(InventoryCorrectionPage::class)->receipts($records->pluck('data.receipt_item_id')->push($page['lookup']['receipt']), $page['lookup']['search']) as $line) { $receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail(); $receipts[] = ['id' => $line->id, 'stock_item_id' => $line->stock_item_id, 'warehouse_id' => $receipt->warehouse_id, 'accepted_milli' => $line->accepted_milli, 'returned_milli' => $workflow->returnedForReceipt($line->id), 'label' => $receipt->number.' · '.$receipt->snapshot['supplier']['name'].' · '.$line->snapshot['stock_item']['name']]; @@ -54,13 +67,23 @@ class InventoryDispositionController extends Controller } } - return Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, 'records' => $records, + Inertia::encryptHistory(); + $response = Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, + 'recordPagination' => $page['pagination'], 'filters' => $page['filters'], + 'sourceSearch' => $page['lookup']['search'], 'sourceFormKey' => $page['lookup']['form'], + 'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id, + 'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $workflow->canAct($actor, $record, 'approve'), + 'canReturn' => $workflow->canAct($actor, $record, 'return'), 'canExecute' => $workflow->canAct($actor, $record, 'execute'), + 'executionSourceCurrent' => $record->state === 'approved' ? $workflow->executionSourceCurrent($record) : null]), 'canViewOperations' => $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'inventory_adjust_review']), 'canViewReversals' => $canViewReversals, 'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->only($records->pluck('data.stock_operation_id')->filter()->all())->map(fn ($r) => $r->data['reference']), 'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'receipts' => $receipts, 'lots' => $lots, - 'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_event')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]); + 'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_event')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; } public function save(Request $request, InventoryDispositions $workflow): RedirectResponse diff --git a/app/Http/Controllers/InventoryLotController.php b/app/Http/Controllers/InventoryLotController.php index d7f57dc..d1c9845 100644 --- a/app/Http/Controllers/InventoryLotController.php +++ b/app/Http/Controllers/InventoryLotController.php @@ -10,9 +10,12 @@ use App\Services\InventoryLots; use App\Services\SupplyPlanning; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Validation\Rule; use Inertia\Inertia; -use Inertia\Response; +use Symfony\Component\HttpFoundation\Response; class InventoryLotController extends Controller { @@ -25,20 +28,76 @@ class InventoryLotController extends Controller $capabilities[$capability] = $lots->allows($actor, 'inventory_lot_'.$capability); } abort_unless(collect($capabilities)->contains(fn (bool $allowed) => $allowed), 403); + + return DB::transaction(function () use ($request, $lots, $actor, $capabilities) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + + return $this->page($request, $lots, $actor, $capabilities); + }); + } + + /** @param array $capabilities */ + private function page(Request $request, InventoryLots $lots, User $actor, array $capabilities): Response + { $ready = Schema::hasTable('inventory_lot_allocations'); - $records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->limit(100)->get() : collect(); + $filter = $request->validate([ + 'record_id' => ['nullable', Rule::prohibitedIf($request->filled('lot_id')), 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'lot_setup')], + 'lot_id' => ['nullable', 'required_with:warehouse_id', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'lot')->where('version', 1)], + 'warehouse_id' => ['nullable', 'required_with:lot_id', 'integer', 'min:1', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], + 'controls_page' => 'nullable|integer|min:1|max:1000000', + 'history_page' => 'nullable|integer|min:1|max:1000000', + ]); + $focus = isset($filter['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', 'lot_setup')->findOrFail((int) $filter['record_id']) : null; + if (isset($filter['lot_id'])) { + $identity = SupplyRecord::query()->findOrFail((int) $filter['lot_id']); + $focus = $lots->setup($identity->stock_item_id, (int) $filter['warehouse_id']); + abort_if($focus === null, 404); + } + $controls = $ready && $focus === null ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->paginate(25, ['record_key'], 'controls_page')->withQueryString() : null; + $keys = $focus !== null ? collect([$focus->record_key]) : ($controls?->getCollection()->pluck('record_key') ?? collect()); + $records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->whereIn('record_key', $keys)->latest('id')->get() : collect(); if ($ready) { - $records = $records->concat($lots->activeSetups()->latest('id')->limit(100)->get())->unique('id')->sortByDesc('id')->values(); + $records = $records->concat($lots->activeSetups()->whereIn('record_key', $keys)->latest('id')->get())->unique('id')->sortByDesc('id')->values(); } $contexts = []; foreach ($records->where('state', 'active') as $record) { $contexts[$record->record_key] = $lots->context($record->stock_item_id, $record->warehouse_id); + foreach ($contexts[$record->record_key]['lots'] as &$entry) { + $metadata = SupplyRecord::query()->whereKey($entry['metadata_id'])->firstOrFail(); + $entry['canIdentify'] = $lots->canIdentify($actor, $metadata); + $entry['canRelease'] = $lots->canDecideLot($actor, $metadata, $contexts[$record->record_key], $entry, 'release'); + $entry['canHold'] = $lots->canDecideLot($actor, $metadata, $contexts[$record->record_key], $entry, 'hold'); + } + unset($entry); } + if (isset($filter['lot_id'])) { + abort_unless(collect($contexts[$focus->record_key]['lots'] ?? [])->contains('id', (int) $filter['lot_id']), 404); + } + $history = $ready ? SupplyRecord::query()->whereIn('kind', ['lot_setup', 'lot', 'lot_state'])->latest('id')->paginate(50, ['*'], 'history_page')->withQueryString() : null; - return Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities, 'records' => $records, 'contexts' => $contexts, + Inertia::encryptHistory(); + $response = Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities, + 'focusRecordId' => $focus->id ?? null, 'focusLotId' => isset($filter['lot_id']) ? (int) $filter['lot_id'] : null, + 'focusChanged' => ! isset($filter['lot_id']) && $focus !== null && $records->first()?->id !== $focus->id, + 'controlPagination' => $this->pagination($controls), 'historyPagination' => $this->pagination($history), + 'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canActivate' => $lots->canActivate($actor, $record)]), 'contexts' => $contexts, 'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], - 'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['lot_setup', 'lot', 'lot_state'])->latest('id')->limit(200)->get() : []]); + 'history' => $history?->getCollection() ?? []])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + /** @param LengthAwarePaginator|null $page + * @return array|null + */ + private function pagination(?LengthAwarePaginator $page): ?array + { + return $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(), + 'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()]; } public function save(Request $request, InventoryLots $lots): RedirectResponse diff --git a/app/Http/Controllers/InventoryReversalController.php b/app/Http/Controllers/InventoryReversalController.php index 1de7706..4a88a2f 100644 --- a/app/Http/Controllers/InventoryReversalController.php +++ b/app/Http/Controllers/InventoryReversalController.php @@ -3,18 +3,17 @@ namespace App\Http\Controllers; use App\Models\GoodsReceipt; -use App\Models\GoodsReceiptItem; -use App\Models\StockOperation; use App\Models\SupplyRecord; use App\Models\User; +use App\Services\InventoryCorrectionPage; use App\Services\InventoryDispositions; use App\Services\InventoryReversals; -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 Inertia\Response; +use Symfony\Component\HttpFoundation\Response; class InventoryReversalController extends Controller { @@ -27,29 +26,46 @@ class InventoryReversalController extends Controller $capabilities[$capability] = $workflow->allows($actor, $capability); } abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403); - $ready = Schema::hasTable('supply_records'); - $records = $ready ? app(SupplyPlanning::class)->latest('stock_reversal')->latest('id')->limit(100)->get() : collect(); + + return DB::transaction(function () use ($request, $workflow, $actor, $capabilities) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + + return $this->page($request, $workflow, $actor, $capabilities); + }); + } + + /** @param array $capabilities */ + private function page(Request $request, InventoryReversals $workflow, User $actor, array $capabilities): Response + { + $page = app(InventoryCorrectionPage::class)->read($request, $actor, 'stock_reversal'); + ['ready' => $ready, 'focus' => $focus, 'records' => $records] = $page; $operations = []; $receipts = []; if ($ready && $capabilities['prepare']) { - $corrected = $workflow->corrections('operation'); - $operations = StockOperation::query()->whereIn('kind', ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'])->latest('id')->limit(200)->get() - ->reject(fn ($op) => isset($corrected[$op->id]))->map(fn ($op) => ['id' => $op->id, 'label' => $op->reference.' · '.$op->snapshot['stock_item']['name'].' · '.$op->snapshot['warehouse']['name']])->values()->all(); - $correctedReceipts = $workflow->corrections('receipt'); - foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) { - if (isset($correctedReceipts[$line->id])) { - continue; - } + $operations = app(InventoryCorrectionPage::class)->operations($records->pluck('data.source_operation_id')->push($page['lookup']['operation']), $page['lookup']['search']) + ->map(fn ($op) => ['id' => $op->id, 'label' => $op->reference.' · '.$op->snapshot['stock_item']['name'].' · '.$op->snapshot['warehouse']['name']])->values()->all(); + foreach (app(InventoryCorrectionPage::class)->receipts($records->pluck('data.receipt_item_id')->push($page['lookup']['receipt']), $page['lookup']['search']) as $line) { $receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail(); $receipts[] = ['id' => $line->id, 'label' => $receipt->number.' · '.$line->snapshot['stock_item']['name'].' · '.$line->snapshot['warehouse']['name']]; } } $other = app(InventoryDispositions::class); - return Inertia::render('stock/reversals', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, + Inertia::encryptHistory(); + $response = Inertia::render('stock/reversals', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, + 'recordPagination' => $page['pagination'], 'filters' => $page['filters'], + 'sourceSearch' => $page['lookup']['search'], 'sourceFormKey' => $page['lookup']['form'], + 'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id, 'canViewDispositions' => collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => $other->allows($actor, $a)), - 'records' => $records, 'operations' => $operations, 'receipts' => $receipts, - 'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_reversal')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]); + 'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $workflow->canAct($actor, $record, 'approve'), + 'canReturn' => $workflow->canAct($actor, $record, 'return'), 'canExecute' => $workflow->canAct($actor, $record, 'execute'), + 'executionSourceCurrent' => $record->state === 'approved' ? $workflow->executionSourceCurrent($record) : null]), 'operations' => $operations, 'receipts' => $receipts, + 'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_reversal')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; } public function save(Request $request, InventoryReversals $workflow): RedirectResponse diff --git a/app/Http/Controllers/ProcurementReportController.php b/app/Http/Controllers/ProcurementReportController.php index 32087b3..091f22c 100644 --- a/app/Http/Controllers/ProcurementReportController.php +++ b/app/Http/Controllers/ProcurementReportController.php @@ -8,6 +8,7 @@ use App\Models\PurchaseOrder; use App\Models\Quotation; use App\Models\Requisition; use App\Models\User; +use App\Services\OrganizationAccess; use App\Services\ProcurementOperationalInsights; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Builder; @@ -19,6 +20,14 @@ use Symfony\Component\HttpFoundation\Response; class ProcurementReportController extends Controller { + private function actor(Request $request): User + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + + return $actor; + } + private function snapshot(\Closure $read): mixed { if (DB::connection()->getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) { @@ -37,8 +46,10 @@ class ProcurementReportController extends Controller /** @return array */ private function filters(Request $request): array { - $actor = User::query()->findOrFail($request->user()->id); - abort_unless($actor->tenant_id === $request->user()->tenant_id && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); + $current = $request->user('web'); + abort_unless($current instanceof User, 403); + $actor = User::query()->where('tenant_id', $current->tenant_id)->findOrFail($current->id); + abort_unless(! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); $request->setUserResolver(fn () => $actor); $filters = $request->validate([ @@ -47,6 +58,7 @@ class ProcurementReportController extends Controller 'from' => 'nullable|date_format:Y-m-d', 'to' => 'nullable|date_format:Y-m-d', 'department_id' => 'nullable|integer|min:1', + 'attention' => 'nullable|in:unapproved_needed,approved_without_quotes,approved_without_order', ]); if (! empty($filters['from']) && ! empty($filters['to']) && $filters['from'] > $filters['to']) { throw ValidationException::withMessages(['to' => 'A data final deve ser igual ou posterior à data inicial.']); @@ -55,10 +67,22 @@ class ProcurementReportController extends Controller return $filters; } + /** @return Builder */ + private function registeredQuotes(): Builder + { + return Quotation::query()->whereColumn('quotations.requisition_id', 'requisitions.id'); + } + + /** @return Builder */ + private function visibleOrders(User $actor): Builder + { + return PurchaseOrder::query()->visibleTo($actor)->whereColumn('purchase_orders.requisition_id', 'requisitions.id'); + } + /** @param array $filters * @return Builder */ - private function query(User $actor, array $filters): Builder + private function query(User $actor, array $filters, string $today): Builder { $query = Requisition::query()->where('tenant_id', $actor->tenant_id)->visibleTo($actor); if (isset($filters['search']) && $filters['search'] !== '') { @@ -69,6 +93,15 @@ class ProcurementReportController extends Controller $query->where($field, $filters[$field]); } } + if (($filters['attention'] ?? '') === 'unapproved_needed') { + $query->whereIn('status', ['draft', 'pending'])->whereDate('needed_by', '<', $today); + } + if (($filters['attention'] ?? '') === 'approved_without_quotes') { + $query->where('status', 'approved')->whereNotExists($this->registeredQuotes()->selectRaw('1')); + } + if (($filters['attention'] ?? '') === 'approved_without_order') { + $query->where('status', 'approved')->whereNotExists($this->visibleOrders($actor)->selectRaw('1')); + } if (! empty($filters['from'])) { $query->where('created_at', '>=', $filters['from'].' 00:00:00'); } @@ -82,11 +115,11 @@ class ProcurementReportController extends Controller /** @param Builder $query * @return Builder */ - private function records(Builder $query): Builder + private function records(Builder $query, User $actor): Builder { return $query->select('requisitions.*')->with(['department:id,name', 'requester:id,name'])->addSelect([ - 'quotations_count' => Quotation::query()->selectRaw('COUNT(*)')->whereColumn('requisition_id', 'requisitions.id'), - 'order_number' => PurchaseOrder::query()->visibleTo(request()->user())->select('number')->whereColumn('requisition_id', 'requisitions.id')->orderBy('id')->limit(1), + 'quotations_count' => $this->registeredQuotes()->selectRaw('COUNT(*)'), + 'order_number' => $this->visibleOrders($actor)->select('number')->orderBy('id')->limit(1), ]); } @@ -96,6 +129,7 @@ class ProcurementReportController extends Controller return [ 'id' => $record->id, 'number' => $record->number, 'status' => $record->status, 'created_at' => $record->created_at?->toIso8601String(), + 'needed_by' => $record->needed_by?->toDateString(), 'department' => $record->department?->name, 'requester' => $record->requester?->name, 'total_minor' => (string) $record->total_minor, 'quotations_count' => (int) $record->getAttribute('quotations_count'), @@ -106,14 +140,15 @@ class ProcurementReportController extends Controller public function index(Request $request): Response { $props = $this->snapshot(function () use ($request): array { + $instant = CarbonImmutable::now(config('app.timezone')); $filters = $this->filters($request); - $query = $this->query($request->user(), $filters); + $actor = $this->actor($request); + $query = $this->query($actor, $filters, $instant->toDateString()); $counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($value) => (int) $value); - $records = $this->records(clone $query)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record)); - $departments = Department::query()->when($request->user()->departmentScoped(), fn ($q) => $q->whereKey($request->user()->department_id))->orderBy('name')->get(['id', 'name']); + $records = $this->records(clone $query, $actor)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record)); + $departments = Department::query()->when($actor->departmentScoped(), fn ($q) => $q->whereKey($actor->department_id))->orderBy('name')->get(['id', 'name']); - $instant = CarbonImmutable::now(); - $insights = app(ProcurementOperationalInsights::class)->build($this->records(clone $query), $instant); + $insights = app(ProcurementOperationalInsights::class)->build($this->records(clone $query, $actor), $instant); return ['records' => $records, 'counts' => $counts, 'filters' => $filters, 'departments' => $departments, 'generatedAt' => $instant->toIso8601String(), 'insights' => $insights]; }); @@ -127,23 +162,26 @@ class ProcurementReportController extends Controller public function export(Request $request): Response { - $filters = $this->filters($request); - $query = $this->records($this->query($request->user(), $filters)); + $this->filters($request); $text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? ''); - return response()->streamDownload(fn () => $this->snapshot(function () use ($query, $text, $filters) { + return response()->streamDownload(fn () => $this->snapshot(function () use ($request, $text) { + $instant = CarbonImmutable::now(config('app.timezone')); + $filters = $this->filters($request); + $actor = $this->actor($request); + $query = $this->records($this->query($actor, $filters, $instant->toDateString()), $actor); $output = fopen('php://output', 'w'); if ($output === false) { throw new \RuntimeException('Não foi possível gerar o ficheiro.'); } fwrite($output, "\xEF\xBB\xBF"); - fputcsv($output, ['Relatório de compras', 'Criado em', now()->toIso8601String()], ';', '"', ''); + fputcsv($output, ['Relatório de compras', 'Criado em', $instant->toIso8601String()], ';', '"', ''); fputcsv($output, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', ''); - fputcsv($output, ['Requisição', 'Criada em', 'Departamento', 'Solicitante', 'Estado da requisição', 'Estimativa total (centavos MZN)', 'Propostas registadas', 'Ordem de compra', 'Processo', 'Relatório de conferência'], ';', '"', ''); + fputcsv($output, ['Requisição', 'Criada em', 'Departamento', 'Solicitante', 'Estado da requisição', 'Estimativa total (centavos MZN)', 'Propostas registadas', 'Ordem de compra', 'Processo', 'Relatório de conferência', 'Data de necessidade'], ';', '"', ''); foreach ($query->lazyById(200) as $record) { $row = $this->row($record); $base = url('/requisicoes/'.$record->id); - fputcsv($output, [$text($record->number), $row['created_at'], $text($row['department']), $text($row['requester']), $record->status, $row['total_minor'], $row['quotations_count'], $text($row['order_number']), $base.'/processo', $base.'/relatorio'], ';', '"', ''); + fputcsv($output, [$text($record->number), $row['created_at'], $text($row['department']), $text($row['requester']), $record->status, $row['total_minor'], $row['quotations_count'], $text($row['order_number']), $base.'/processo', $base.'/relatorio', $row['needed_by']], ';', '"', ''); } fclose($output); }), 'relatorio-compras-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']); diff --git a/app/Http/Controllers/ReceiptDocumentController.php b/app/Http/Controllers/ReceiptDocumentController.php new file mode 100644 index 0000000..6b3a26f --- /dev/null +++ b/app/Http/Controllers/ReceiptDocumentController.php @@ -0,0 +1,60 @@ +user('web'); + abort_unless($actor instanceof User, 403); + Gate::authorize('view', $receipt); + abort_unless($documents->canRead($actor, $receipt), 403); + abort_unless($documents->canAdd($actor, $receipt), 403); + $file = $request->file('file'); + abort_unless($file instanceof UploadedFile, 422); + $documents->add($actor, $receipt, $request->only(['operation_key', 'document_type']), $file); + + return redirect('/recepcoes/'.$receipt->id.'#receipt-documents'); + } + + public function download(Request $request, GoodsReceipt $receipt, int $document, ReceiptDocuments $documents): StreamedResponse + { + $actor = $request->user('web'); + abort_unless($actor instanceof User && $documents->canRead($actor, $receipt), 403); + Gate::authorize('view', $receipt); + abort_unless($documents->ready(), 503); + $file = ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->findOrFail($document); + $disk = Storage::disk('receipt_documents'); + abort_unless($disk->exists($file->path), 404); + $stream = $disk->readStream($file->path); + abort_unless(is_resource($stream), 404); + $hash = hash_init('sha256'); + hash_update_stream($hash, $stream); + if (! hash_equals($file->sha256, hash_final($hash))) { + fclose($stream); + abort(409, 'Documento indisponível para descarga. Solicite conferência ao responsável.'); + } + rewind($stream); + + return response()->streamDownload(function () use ($stream) { + try { + fpassthru($stream); + } finally { + fclose($stream); + } + }, $file->original_name, ['Content-Type' => 'application/octet-stream', 'X-Content-Type-Options' => 'nosniff', + 'Cache-Control' => 'private, no-store', 'Content-Security-Policy' => "default-src 'none'; sandbox"]); + } +} diff --git a/app/Http/Controllers/RequisitionAuditController.php b/app/Http/Controllers/RequisitionAuditController.php new file mode 100644 index 0000000..be2a9a1 --- /dev/null +++ b/app/Http/Controllers/RequisitionAuditController.php @@ -0,0 +1,59 @@ +visible($request, $requisition); + + return response()->streamDownload(function () use ($request, $requisition, $trail) { + DB::transaction(function () use ($request, $requisition, $trail) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + $source = $this->visible($request, $requisition); + $out = fopen('php://output', 'w'); + if ($out === false) { + throw new \RuntimeException('Não foi possível preparar a exportação.'); + } + $text = static function (mixed $value): string { + $value = (string) ($value ?? ''); + + return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value; + }; + try { + fwrite($out, "\xEF\xBB\xBF"); + fputcsv($out, ['Metadados do histórico da requisição', $text($source->number), 'Conferido em', now()->toIso8601String()], ';', '"', ''); + fputcsv($out, ['Registo', 'Data registada', 'Acção', 'Utilizador (id)', 'Modo de execução'], ';', '"', ''); + foreach ($trail->query($source)->lazyById(100) as $record) { + $row = $trail->row($record); + fputcsv($out, array_map($text, [$row['id'], $row['created_at'], $row['action'], $row['user_id'], $row['execution']]), ';', '"', ''); + } + } finally { + fclose($out); + } + }); + }, 'historico-requisicao-'.$requisition.'-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']); + } + + private function visible(Request $request, int $id): Requisition + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id); + $access = app(OrganizationAccess::class); + abort_unless(! $access->maintenanceOnly($actor) && $access->permissions($actor) !== [], 403); + + return Requisition::query()->visibleTo($actor)->findOrFail($id); + } +} diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php index 10526dc..10460dd 100644 --- a/app/Http/Controllers/RequisitionController.php +++ b/app/Http/Controllers/RequisitionController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreRequisitionRequest; -use App\Models\AuditLog; use App\Models\CostCenter; use App\Models\Equipment; use App\Models\EquipmentSparePart; @@ -19,12 +18,14 @@ use App\Services\ConflictGuard; use App\Services\EquipmentWorkflow; use App\Services\InternalSupplyTrace; use App\Services\OrganizationAccess; +use App\Services\RequisitionAuditTrail; use App\Services\RequisitionCatalogue; use App\Services\RequisitionWorkflow; use App\Services\SupplySuggestions; use App\Services\WorkContext; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Validation\Rule; use Inertia\Inertia; @@ -88,7 +89,22 @@ class RequisitionController extends Controller public function show(Request $request, Requisition $requisition): Response { - Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id); + return DB::transaction(function () use ($request, $requisition) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + + return $this->detail($request, $requisition); + }); + } + + private function detail(Request $request, Requisition $requisition): Response + { + $requisition = Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id); + Inertia::encryptHistory(); + $request->validate(['audit_page' => 'nullable|integer|min:1|max:1000000']); + $trail = app(RequisitionAuditTrail::class); + $audit = $trail->query($requisition)->orderBy('id')->paginate(25, ['*'], 'audit_page')->withQueryString(); $requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']); return Inertia::render('requisitions/show', [ @@ -101,7 +117,9 @@ class RequisitionController extends Controller '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]), + 'audit' => $audit->getCollection()->map(fn ($a) => $trail->row($a)), + 'auditPagination' => ['total' => $audit->total(), 'current_page' => $audit->currentPage(), 'last_page' => $audit->lastPage(), + 'prev_page_url' => $audit->previousPageUrl(), 'next_page_url' => $audit->nextPageUrl()], 'technical' => [ 'canReview' => app(EquipmentWorkflow::class)->canReview($this->actor($request), $requisition), 'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition), diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 9582a37..b882285 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -5,10 +5,14 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; use App\Http\Requests\Settings\PasswordUpdateRequest; use App\Http\Requests\Settings\TwoFactorAuthenticationRequest; +use App\Models\User; +use Illuminate\Auth\Middleware\RequirePassword; use Illuminate\Http\RedirectResponse; use Illuminate\Validation\Rules\Password; use Inertia\Inertia; -use Inertia\Response; +use Laravel\Fortify\Features; +use Laravel\Fortify\Fortify; +use Symfony\Component\HttpFoundation\Response; class SecurityController extends Controller { @@ -17,11 +21,34 @@ class SecurityController extends Controller */ public function edit(TwoFactorAuthenticationRequest $request): Response { + if (Features::enabled(Features::twoFactorAuthentication())) { + return app(RequirePassword::class)->handle($request, fn () => $this->page($request)); + } + + return $this->page($request); + } + + private function page(TwoFactorAuthenticationRequest $request): Response + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); $props = [ 'passwordRules' => Password::defaults()->toPasswordRulesString(), ]; + if (Features::enabled(Features::twoFactorAuthentication())) { + $props += [ + 'canManageTwoFactor' => true, + 'twoFactorEnabled' => $actor->hasEnabledTwoFactorAuthentication(), + 'twoFactorPending' => $actor->two_factor_secret !== null && ! $actor->hasEnabledTwoFactorAuthentication(), + 'requiresConfirmation' => Fortify::confirmsTwoFactorAuthentication(), + ]; + } - return Inertia::render('settings/security', $props); + Inertia::encryptHistory(); + $response = Inertia::render('settings/security', $props)->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; } /** diff --git a/app/Http/Controllers/SupplierController.php b/app/Http/Controllers/SupplierController.php index a4a92e7..4f9ad27 100644 --- a/app/Http/Controllers/SupplierController.php +++ b/app/Http/Controllers/SupplierController.php @@ -5,7 +5,11 @@ namespace App\Http\Controllers; use App\Enums\PurchasingRole; use App\Http\Requests\SaveSupplierRequest; use App\Models\Supplier; +use App\Models\User; +use App\Services\ConflictGuard; +use App\Services\CurrentActor; use App\Services\RequisitionWorkflow; +use App\Services\SupplierParticipation; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -14,44 +18,67 @@ use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\HttpFoundation\Response as HttpResponse; class SupplierController extends Controller { + private function actor(Request $request): User + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + + return $actor; + } + + private function currentActor(Request $request, PurchasingRole $role): User + { + $actor = $this->actor($request); + app(ConflictGuard::class)->lockCompany($actor); + + return app(CurrentActor::class)->lock($actor, [$role]); + } + public function index(Request $request): Response { - 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); $filters = $request->validate(['search' => 'nullable|string|max:100']); $query = Supplier::query(); if (! empty($filters['search'])) { $query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%')); } - return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance])]); + return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => app(SupplierParticipation::class)->canPrepare($this->actor($request)), 'canReview' => app(SupplierParticipation::class)->canReviewRole($this->actor($request))]); } public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse { $data = $request->validated(); DB::transaction(function () use ($request, $data, $workflow) { - $supplier = Supplier::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'last_changed_by' => $request->user()->id, 'status' => 'pending']); - $workflow->audit($request->user(), 'supplier.created', null, null, $supplier->toArray()); + $actor = $this->currentActor($request, PurchasingRole::Procurement); + abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403); + $supplier = Supplier::query()->create([...$data, 'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'last_changed_by' => $actor->id, 'status' => 'pending']); + $workflow->audit($actor, 'supplier.created', null, null, $supplier->toArray()); }); return back(); } - public function show(Request $request, Supplier $supplier): Response + public function show(Request $request, Supplier $supplier): HttpResponse { Gate::authorize('view', $supplier); - $canDocuments = $request->user()->can('viewDocuments', $supplier); + $canDocuments = $this->actor($request)->can('viewDocuments', $supplier); - return Inertia::render('suppliers/show', [ + Inertia::encryptHistory(); + $response = Inertia::render('suppliers/show', [ 'supplier' => $supplier, 'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [], - 'canEdit' => $request->user()->can('update', $supplier), + 'canEdit' => $this->actor($request)->can('update', $supplier), 'canDocuments' => $canDocuments, - 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance]), - ]); + 'canReview' => app(SupplierParticipation::class)->canReview($this->actor($request), $supplier), + ])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; } public function edit(Supplier $supplier): Response @@ -66,6 +93,8 @@ class SupplierController extends Controller Gate::authorize('update', $supplier); $data = $request->validated(); DB::transaction(function () use ($request, $supplier, $workflow, $data) { + $actor = $this->currentActor($request, PurchasingRole::Procurement); + abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403); $supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id); if ($supplier->version !== (int) $data['version']) { throw ValidationException::withMessages(['version' => 'A ficha foi alterada por outro utilizador. Reabra a edição para carregar os dados actuais.']); @@ -74,8 +103,8 @@ class SupplierController extends Controller unset($data['version']); $supplier->fill($data); if ($supplier->isDirty()) { - $supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1])->save(); - $workflow->audit($request->user(), 'supplier.updated', null, $before, $supplier->toArray()); + $supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $actor->id, 'version' => $supplier->version + 1])->save(); + $workflow->audit($actor, 'supplier.updated', null, $before, $supplier->toArray()); } }); @@ -84,12 +113,12 @@ class SupplierController extends Controller public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse { - abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403); + abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Finance]), 403); $data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']); DB::transaction(function () use ($request, $supplier, $data, $workflow) { + $actor = $this->currentActor($request, PurchasingRole::Finance); $supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id); - abort_if($supplier->created_by === $request->user()->id, 403); - abort_if($supplier->last_changed_by === $request->user()->id, 403); + abort_unless(app(SupplierParticipation::class)->canReview($actor, $supplier), 403); if ($supplier->version !== (int) $data['version']) { throw ValidationException::withMessages(['version' => 'A ficha ou os documentos foram alterados. Reabra a ficha antes de validar.']); } @@ -97,8 +126,8 @@ class SupplierController extends Controller throw ValidationException::withMessages(['status' => 'Fornecedor impedido ou com revisão vencida. Não pode ser activado.']); } $before = $supplier->toArray(); - $supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $request->user()->id, 'version' => $supplier->version + 1]); - $workflow->audit($request->user(), 'supplier.reviewed', null, $before, $supplier->toArray()); + $supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $actor->id, 'version' => $supplier->version + 1]); + $workflow->audit($actor, 'supplier.reviewed', null, $before, $supplier->toArray()); }); return back(); diff --git a/app/Http/Controllers/SupplierEvaluationController.php b/app/Http/Controllers/SupplierEvaluationController.php index a44c446..088ef91 100644 --- a/app/Http/Controllers/SupplierEvaluationController.php +++ b/app/Http/Controllers/SupplierEvaluationController.php @@ -20,6 +20,14 @@ use Symfony\Component\HttpFoundation\Response; class SupplierEvaluationController extends Controller { + private function actor(Request $request): User + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + + return $actor; + } + private function ready(): void { abort_unless(Schema::hasTable('supplier_evaluations'), 503, 'A instalação do módulo de avaliações ainda não foi concluída.'); @@ -43,7 +51,7 @@ class SupplierEvaluationController extends Controller public function index(Request $request, SupplierEvaluationWorkflow $workflow): Response { - $actor = $workflow->reader($request->user()); + $actor = $workflow->reader($this->actor($request)); $filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'status' => 'nullable|in:draft,review,returned,approved']); $ready = Schema::hasTable('supplier_evaluations'); $records = $ready ? SupplierEvaluation::query()->visibleTo($actor) @@ -54,7 +62,7 @@ class SupplierEvaluationController extends Controller 'title' => $e->payload['title'], 'period_start' => $e->payload['period_start'], 'period_end' => $e->payload['period_end'], ]) : null; - return $this->render($request, 'suppliers/evaluations', ['ready' => $ready, 'records' => $records, 'filters' => $filters, + return $this->render($request, 'suppliers/evaluations', ['ready' => $ready, 'canCreate' => $ready && $workflow->canPrepare($actor), 'records' => $records, 'filters' => $filters, 'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name', 'nuit']), 'orders' => PurchaseOrder::query()->visibleTo($actor)->orderByDesc('id')->get(['id', 'number', 'supplier_id'])]); } @@ -71,7 +79,7 @@ class SupplierEvaluationController extends Controller private function view(Request $request, int $id, SupplierEvaluationWorkflow $workflow, bool $report): Response { - $actor = $workflow->reader($request->user()); + $actor = $workflow->reader($this->actor($request)); $this->ready(); $evaluation = SupplierEvaluation::query()->visibleTo($actor)->findOrFail($id); $improvementWorkflow = app(SupplierImprovementWorkflow::class); @@ -89,21 +97,21 @@ class SupplierEvaluationController extends Controller $next = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->value('id'); return $this->render($request, 'suppliers/evaluation', ['evaluation' => $workflow->snapshot($evaluation), 'reportOnly' => $report, - 'canEdit' => ! $report && $writable && in_array($evaluation->status, ['draft', 'returned'], true), - 'canReview' => ! $report && $writable && $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true), - 'canRevise' => ! $report && $writable && $evaluation->status === 'approved' && $next === null, + 'canEdit' => ! $report && $workflow->canAct($actor, $evaluation, 'edit'), + 'canReview' => ! $report && $workflow->canAct($actor, $evaluation, 'review'), + 'canRevise' => ! $report && $workflow->canAct($actor, $evaluation, 'revise') && $next === null, 'nextRevisionId' => $next, 'history' => $history, 'people' => $people, 'responses' => ['ready' => $responsesReady, 'canCreate' => ! $report && $responsesReady && $writable && $evaluation->status === 'approved', 'history' => $responseHistory, 'records' => $responses->map(fn (SupplierResponse $r) => [...app(SupplierResponseWorkflow::class)->snapshot($r), - 'canEdit' => ! $report && $writable && in_array($r->status, ['draft', 'returned'], true), - 'canReview' => ! $report && $writable && $r->status === 'review' && ! in_array($actor->id, $r->contributors, true)])], + 'canEdit' => ! $report && app(SupplierResponseWorkflow::class)->canAct($actor, $evaluation, $r, 'edit'), + 'canReview' => ! $report && app(SupplierResponseWorkflow::class)->canAct($actor, $evaluation, $r, 'review')])], 'improvements' => ['ready' => $measuresReady, 'canCreate' => ! $report && $measuresReady && $writable && $evaluation->status === 'approved', 'today' => today()->toDateString(), 'records' => $measures->map(fn (SupplierImprovement $m) => [...$improvementWorkflow->snapshot($m), - 'canPlan' => ! $report && $writable && in_array($m->status, ['open', 'returned'], true), - 'canExecute' => ! $report && $writable && $actor->id === $m->responsible_id && in_array($m->status, ['open', 'returned'], true), - 'canReview' => ! $report && $writable && $m->status === 'review' && ! in_array($actor->id, $m->contributors, true)]), + 'canPlan' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'plan'), + 'canExecute' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'execute'), + 'canReview' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'review')]), 'history' => $measureHistory, 'assignees' => $report ? [] : User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->filter(fn (User $u) => $u->hasFunctions(SupplierEvaluationWorkflow::ROLES) && (! $u->departmentScoped() || ($u->department_id !== null && $u->department_id === $evaluation->department_id)))->map(fn (User $u) => $u->only(['id', 'name']))->values()], 'orders' => PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $evaluation->supplier_id)->orderByDesc('id')->get(['id', 'number', 'supplier_id']), @@ -124,7 +132,7 @@ class SupplierEvaluationController extends Controller { $this->ready(); $data = $request->validate(['supplier_id' => 'required|integer|min:1', 'operation_key' => 'required|uuid']); - $evaluation = $workflow->create($request->user(), (int) $data['supplier_id'], $data['operation_key'], $this->payload($request)); + $evaluation = $workflow->create($this->actor($request), (int) $data['supplier_id'], $data['operation_key'], $this->payload($request)); return to_route('supplier-evaluations.show', $evaluation->id)->with('supplier_evaluation_notice', 'Rascunho guardado. Os dados podem ser recuperados nesta avaliação.'); } @@ -133,7 +141,7 @@ class SupplierEvaluationController extends Controller { $this->ready(); $data = $request->validate(['version' => 'required|integer|min:1']); - $workflow->save($request->user(), $evaluation, (int) $data['version'], $this->payload($request)); + $workflow->save($this->actor($request), $evaluation, (int) $data['version'], $this->payload($request)); return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Alterações guardadas no histórico.'); } @@ -143,7 +151,7 @@ class SupplierEvaluationController extends Controller $this->ready(); $data = $request->validate(['version' => 'required|integer|min:1', 'reason' => $action === 'submit' ? 'nullable|string|max:4000' : 'required|string|min:10|max:4000', 'payload' => 'prohibited', 'supplier_id' => 'prohibited', 'tenant_id' => 'prohibited', 'reviewed_by' => 'prohibited']); - $workflow->transition($request->user(), $evaluation, (int) $data['version'], $action, $data['reason'] ?? null); + $workflow->transition($this->actor($request), $evaluation, (int) $data['version'], $action, $data['reason'] ?? null); return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Estado e decisão registados.'); } @@ -152,7 +160,7 @@ class SupplierEvaluationController extends Controller { $this->ready(); $data = $request->validate(['version' => 'required|integer|min:1']); - $revision = $workflow->revise($request->user(), $evaluation, (int) $data['version']); + $revision = $workflow->revise($this->actor($request), $evaluation, (int) $data['version']); return to_route('supplier-evaluations.show', $revision->id)->with('supplier_evaluation_notice', 'Nova revisão preparada. A avaliação aprovada anterior mantém-se preservada.'); } diff --git a/app/Http/Controllers/SupplierPayablesController.php b/app/Http/Controllers/SupplierPayablesController.php index 5652ca6..051c43e 100644 --- a/app/Http/Controllers/SupplierPayablesController.php +++ b/app/Http/Controllers/SupplierPayablesController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Tenant; +use App\Models\User; use App\Services\SupplierPayables; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -11,20 +12,42 @@ use Tempo\Finance\Payables\Workspace; final class SupplierPayablesController extends Controller { + private function actor(Request $r): User + { + $user = $r->user(); + abort_unless($user instanceof User, 403); + + return $user; + } + + /** @return array */ + private function filters(Request $r): array + { + $filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', + 'proposal_page' => 'sometimes|integer|min:1|max:100000', 'event_page' => 'sometimes|integer|min:1|max:100000', + 'per_page' => 'sometimes|integer|in:25,100', 'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']); + abort_if(array_diff(array_keys($r->query()), array_keys($filter)) !== [], 422); + + return $filter; + } + public function index(Request $r, SupplierPayables $s): JsonResponse { - $filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']); + $filter = $this->filters($r); + $options = array_diff_key($filter, ['invoice' => true]); - return response()->json($s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null))->header('Cache-Control', 'no-store'); + return response()->json($s->listing($this->actor($r), isset($filter['invoice']) ? (int) $filter['invoice'] : null, $options))->header('Cache-Control', 'no-store'); } public function page(Request $r, SupplierPayables $s): Response { - $filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']); - $data = $s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null); - $company = Tenant::query()->findOrFail($r->user()->tenant_id)->name; + $filter = $this->filters($r); + $options = array_diff_key($filter, ['invoice' => true]); + $actor = $this->actor($r); + $data = $s->listing($actor, isset($filter['invoice']) ? (int) $filter['invoice'] : null, $options); + $company = Tenant::query()->findOrFail($actor->tenant_id)->name; - return response(Workspace::render($data, ['host' => 'Compras autónomo', 'actor' => $r->user()->name, 'organisation' => $company, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'return_url' => '/facturas', 'return_label' => 'Voltar às facturas']))->header('Cache-Control', 'no-store'); + return response(Workspace::render($data, ['host' => 'Compras autónomo', 'actor' => $actor->name, 'organisation' => $company, 'api' => '/api/supplier-payables', 'page_url' => '/obrigacoes-fornecedores', 'csrf' => csrf_token(), 'return_url' => '/facturas', 'return_label' => 'Voltar às facturas']))->header('Cache-Control', 'no-store'); } public function command(Request $r, string $action, SupplierPayables $s): JsonResponse @@ -33,7 +56,7 @@ final class SupplierPayablesController extends Controller $d = $r->validate(['id' => 'required|integer|min:1', 'reason' => 'required|string|min:3|max:2000']); abort_if(array_diff(array_keys($r->all()), ['id', 'reason']) !== [], 422); try { - return response()->json($s->engine($r->user())->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key'))); + return response()->json($s->engine($this->actor($r))->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key'))); } catch (\DomainException $e) { abort(409, $e->getMessage()); } catch (\InvalidArgumentException $e) { diff --git a/app/Http/Controllers/SupplierPaymentsController.php b/app/Http/Controllers/SupplierPaymentsController.php index 3493d44..ac89308 100644 --- a/app/Http/Controllers/SupplierPaymentsController.php +++ b/app/Http/Controllers/SupplierPaymentsController.php @@ -2,7 +2,10 @@ namespace App\Http\Controllers; +use App\Models\User; +use App\Services\GeneralLedger; use App\Services\SupplierPayables; +use Illuminate\Http\Request; use Tempo\Finance\Payables\SettlementHttp; final class SupplierPaymentsController extends SettlementHttp @@ -10,4 +13,16 @@ final class SupplierPaymentsController extends SettlementHttp protected const MODE = 'compras'; protected const SERVICE = SupplierPayables::class; + + /** @return array */ + protected function ledgerSettings(Request $request): array + { + if (config('general-ledger.cycle_enabled') !== true) { + return []; + } + $user = $request->user(); + abort_unless($user instanceof User, 403); + + return app(GeneralLedger::class)->can($user, 'view') ? ['ledger_cycle_url' => '/general-ledger/cycle'] : []; + } } diff --git a/app/Http/Controllers/SupplyCoordinationReportController.php b/app/Http/Controllers/SupplyCoordinationReportController.php new file mode 100644 index 0000000..e215ad3 --- /dev/null +++ b/app/Http/Controllers/SupplyCoordinationReportController.php @@ -0,0 +1,74 @@ +user('web'); + abort_unless($actor instanceof User, 403); + $view = $request->validate(['view' => 'required|in:reservations,deliveries,alternatives'])['view']; + $kind = $read->kind($actor, $view); + $filters = $read->filters($request, $kind); + abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.'); + // Validate the same focus as the page, without silently exporting other records. + $focus = $request->validate(['record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)]]); + + return response()->streamDownload(function () use ($read, $actor, $view, $kind, $filters, $focus) { + DB::transaction(function () use ($read, $actor, $view, $kind, $filters, $focus) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + // Recheck access when the response is consumed. + $read->kind($actor, $view); + $query = $read->query($kind, isset($focus['record_id']) ? ['record_search' => '', 'record_state' => ''] : $filters); + if (isset($focus['record_id'])) { + $key = SupplyRecord::query()->whereKey($focus['record_id'])->value('record_key'); + $query->where('record_key', $key); + } + $out = fopen('php://output', 'w'); + if ($out === false) { + throw new \RuntimeException('Não foi possível preparar a exportação.'); + } + $text = static function (mixed $value): string { + $value = (string) ($value ?? ''); + + return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value; + }; + try { + fwrite($out, "\xEF\xBB\xBF"); + fputcsv($out, ['Acompanhamento de fornecimentos', $view, 'Conferido em', now()->toIso8601String()], ';', '"', ''); + fputcsv($out, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), 'Registo em foco', $focus['record_id'] ?? ''], ';', '"', ''); + fputcsv($out, ['Registo', 'Versão', 'Estado registado', 'Artigo', 'Designação preservada', 'Local preservado', 'Referência', 'Encomenda', 'Quantidade', 'Unidade', 'Data prevista/necessidade', 'Quantidade por satisfazer (ver estado)', 'Substituto', 'Aplicação', 'Validade', 'Processo'], ';', '"', ''); + foreach ($query->lazyById(100) as $record) { + $pending = match ($kind) { + 'reservation' => app(SupplyCoordination::class)->remainingReservation($record), + 'delivery' => app(SupplyCoordination::class)->remainingDelivery($record), + default => null, + }; + fputcsv($out, array_map($text, [$record->record_key, $record->version, $record->state, + $record->snapshot['item']['code'] ?? '', $record->snapshot['item']['name'] ?? '', $record->snapshot['warehouse']['name'] ?? '', + $record->data['reference'] ?? '', $record->snapshot['order']['number'] ?? '', + isset($record->data['quantity_milli']) ? $record->data['quantity_milli'] / 1000 : '', $record->snapshot['item']['unit'] ?? '', + $record->data['needed_on'] ?? $record->data['expected_on'] ?? '', $pending === null ? '' : $pending / 1000, + $record->snapshot['substitute']['code'] ?? '', $record->data['application'] ?? '', $record->data['valid_until'] ?? '', + url('/compras/fornecimento?view='.$view.'&record_id='.$record->id).'#supply-record-'.$record->id]), ';', '"', ''); + } + } finally { + fclose($out); + } + }); + }, 'fornecimentos-'.$view.'-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']); + } +} diff --git a/app/Http/Controllers/SupplyPlanningController.php b/app/Http/Controllers/SupplyPlanningController.php index 9fefd37..eb98604 100644 --- a/app/Http/Controllers/SupplyPlanningController.php +++ b/app/Http/Controllers/SupplyPlanningController.php @@ -17,37 +17,58 @@ use App\Services\InventoryReversals; use App\Services\OrganizationAccess; use App\Services\ReceiptWorkflow; use App\Services\SupplyCoordination; +use App\Services\SupplyCoordinationRead; +use App\Services\SupplyHistory; use App\Services\SupplyPlanning; use App\Services\SupplySuggestions; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\HttpFoundation\Response as HttpResponse; class SupplyPlanningController extends Controller { - public function index(Request $request, SupplyPlanning $planning): Response + public function index(Request $request, SupplyPlanning $planning): HttpResponse { $actor = $request->user('web'); abort_unless($actor instanceof User, 403); + + return DB::transaction(function () use ($request, $planning, $actor) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + + return $this->page($request, $planning, $actor); + }); + } + + private function page(Request $request, SupplyPlanning $planning, User $actor): HttpResponse + { + Inertia::encryptHistory(); $view = $request->validate(['view' => 'nullable|in:agreements,calendar,imports,alternatives,reservations,deliveries,suggestions,history'])['view'] ?? 'agreements'; if ($view === 'history') { - return $this->history($actor, $planning); + return $this->privateResponse($request, $this->history($request, $actor, $planning)); } if ($view === 'suggestions') { - return $this->suggestions($actor, $planning); + return $this->privateResponse($request, $this->suggestions($request, $actor, $planning)); } if (in_array($view, ['alternatives', 'reservations', 'deliveries'], true)) { - return $this->coordination($actor, $view, $planning); + return $this->privateResponse($request, $this->coordination($request, $actor, $view, $planning)); } $import = $view === 'imports'; $capabilities = $import ? ['import_read', 'import_prepare', 'import_customs'] : ['supply_read', 'supply_prepare', 'supply_review']; abort_unless(collect($capabilities)->contains(fn ($p) => $planning->allows($actor, $p)), 403); $ready = Schema::hasTable('supply_records'); $kind = $import ? 'import' : ($view === 'calendar' ? 'policy' : 'agreement'); - $records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect(); + $focus = $this->focusedRecord($request, $actor, $kind); + [$records, $page] = $this->recordPage($request, $planning, $ready, $kind, $focus); $projections = []; if ($view === 'calendar' && $ready) { foreach ($records->where('state', 'approved') as $record) { @@ -55,39 +76,150 @@ class SupplyPlanningController extends Controller } } $prepare = $planning->allows($actor, $import ? 'import_prepare' : 'supply_prepare'); + $orderFilters = $import ? $request->validate([ + 'order_search' => 'nullable|string|max:100', + 'selected_order_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_orders', 'id')->where('tenant_id', $actor->tenant_id)], + ]) : []; + $historyFilters = $kind === 'policy' ? $request->validate([ + 'history_stock_item_id' => ['nullable', 'required_with:history_period_from,history_period_until,selected_history_key', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $actor->tenant_id)], + 'history_warehouse_id' => ['nullable', 'required_with:history_stock_item_id', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], + 'history_purpose' => 'nullable|required_with:history_stock_item_id|in:internal,resale', + 'history_period_from' => 'nullable|date_format:Y-m-d', + 'history_period_until' => ['nullable', 'date_format:Y-m-d', $request->filled('history_period_from') ? 'after_or_equal:history_period_from' : 'nullable'], + 'selected_history_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'history')], + 'history_form_key' => 'nullable|string|max:80', + ]) : []; - return Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready, 'records' => $records, 'projections' => $projections, + $agreementFilters = $kind === 'policy' ? $request->validate([ + 'agreement_search' => 'nullable|string|max:100', + 'agreement_stock_item_id' => ['nullable', 'required_with:agreement_search,selected_agreement_key', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $actor->tenant_id)], + 'agreement_warehouse_id' => ['nullable', 'required_with:agreement_stock_item_id', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], + 'selected_agreement_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'agreement')], + 'agreement_form_key' => 'nullable|string|max:80', + ]) : []; + + return $this->privateResponse($request, Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready, + 'agreementSearch' => $agreementFilters['agreement_search'] ?? '', 'agreementFormKey' => $agreementFilters['agreement_form_key'] ?? null, + 'recordPagination' => $this->pagination($page), + 'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id, + 'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $planning->canReview($actor, $record), 'canReturn' => $planning->canReview($actor, $record, 'return'), 'canSuspend' => $planning->canReview($actor, $record, 'suspend')]), 'projections' => $projections, 'asOf' => today()->toDateString(), - 'histories' => $ready && $prepare && $kind === 'policy' ? $planning->latest('history')->where('state', 'approved')->latest('id')->limit(100)->get()->map(fn ($r) => [ + 'historyFormKey' => $historyFilters['history_form_key'] ?? null, + 'histories' => $ready && $prepare && $kind === 'policy' ? $this->approvedOptions($planning, 'history', $records->pluck('data.history_key')->push($historyFilters['selected_history_key'] ?? null), $historyFilters)->map(fn ($r) => [ 'key' => $r->record_key, 'version' => $r->version, 'stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'purpose' => $r->data['purpose'], 'period_from' => $r->data['period_from'], 'period_until' => $r->data['period_until'], 'quality' => $r->data['quality'], 'pattern' => $r->data['pattern'], 'days' => $r->snapshot['analysis']['days'], 'daily_mean_milli' => $r->snapshot['analysis']['daily_mean_milli'], ]) : [], - 'agreements' => $ready && $prepare && $kind === 'policy' ? $planning->latest('agreement')->where('state', 'approved')->get(['record_key', 'version', 'stock_item_id', 'warehouse_id', 'data', 'snapshot']) : [], + 'agreements' => $ready && $prepare && $kind === 'policy' ? $this->approvedOptions($planning, 'agreement', $records->pluck('data.agreement_key')->push($agreementFilters['selected_agreement_key'] ?? null), $agreementFilters)->map(fn ($r) => $r->only(['record_key', 'version', 'stock_item_id', 'warehouse_id', 'data', 'snapshot'])) : [], 'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [], 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'), 'canCustoms' => $planning->allows($actor, 'import_customs'), 'items' => $prepare && ! $import ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $prepare && ! $import ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'suppliers' => $prepare && $kind === 'agreement' ? Supplier::query()->where('status', 'active')->orderBy('name')->get(['id', 'name']) : [], - 'orders' => $prepare && $import ? PurchaseOrder::query()->latest('id')->limit(100)->get(['id', 'number']) : [], - ]); + 'orderSearch' => $orderFilters['order_search'] ?? '', + 'orders' => $prepare && $import ? $this->orderOptions($records->pluck('purchase_order_id')->push($orderFilters['selected_order_id'] ?? null), $orderFilters['order_search'] ?? '') : [], + ])); } - private function history(User $actor, SupplyPlanning $planning): Response + private function privateResponse(Request $request, Response $page): HttpResponse + { + $response = $page->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + private function focusedRecord(Request $request, User $actor, string $kind): ?SupplyRecord + { + $filter = $request->validate(['record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)]]); + + return isset($filter['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', $kind)->findOrFail((int) $filter['record_id']) : null; + } + + /** @return array{Collection,LengthAwarePaginator|null} */ + private function recordPage(Request $request, SupplyPlanning $planning, bool $ready, string $kind, ?SupplyRecord $focus): array + { + $request->validate(['records_page' => 'nullable|integer|min:1|max:1000000']); + if (! $ready) { + return [collect(), null]; + } + $query = $planning->latest($kind)->latest('id'); + if ($focus !== null) { + return [$query->where('record_key', $focus->record_key)->get(), null]; + } + $page = $query->paginate(25, ['*'], 'records_page')->withQueryString(); + + return [$page->getCollection(), $page]; + } + + /** @param LengthAwarePaginator|null $page + * @return array|null + */ + private function pagination(?LengthAwarePaginator $page): ?array + { + return $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(), + 'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()]; + } + + /** Preserve already selected sources even outside the recent choices. + * @param Collection $selected + * @param array $filters + * @return Collection + */ + private function approvedOptions(SupplyPlanning $planning, string $kind, Collection $selected, array $filters = []): Collection + { + $query = $planning->latest($kind)->where('state', 'approved'); + if ($kind === 'agreement' && isset($filters['agreement_stock_item_id'])) { + $query->where('stock_item_id', $filters['agreement_stock_item_id'])->where('warehouse_id', $filters['agreement_warehouse_id']); + } + if ($kind === 'history' && isset($filters['history_stock_item_id'])) { + $query->where('stock_item_id', $filters['history_stock_item_id'])->where('warehouse_id', $filters['history_warehouse_id'])->where('data->purpose', $filters['history_purpose']); + } + $recent = (clone $query) + ->when(trim($filters['agreement_search'] ?? '') !== '', fn ($q) => $q->where(fn ($match) => $match + ->whereLike('data->reference', '%'.trim($filters['agreement_search']).'%') + ->orWhereLike('snapshot->supplier->name', '%'.trim($filters['agreement_search']).'%'))) + ->when(trim($filters['policy_search'] ?? '') !== '', fn ($q) => $q->where(fn ($match) => $match + ->whereLike('snapshot->item->code', '%'.trim($filters['policy_search']).'%') + ->orWhereLike('snapshot->item->name', '%'.trim($filters['policy_search']).'%') + ->orWhereLike('snapshot->warehouse->name', '%'.trim($filters['policy_search']).'%'))) + ->when($filters['history_period_from'] ?? null, fn ($q, $date) => $q->where('data->period_from', '>=', $date)) + ->when($filters['history_period_until'] ?? null, fn ($q, $date) => $q->where('data->period_until', '<=', $date)) + ->latest('id')->limit(100)->pluck('id'); + + return $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('record_key', $selected->filter()->unique()))->latest('id')->get(); + } + + /** @param Collection $selected + * @return Collection + */ + private function orderOptions(Collection $selected, string $search = ''): Collection + { + $recent = PurchaseOrder::query()->when(trim($search) !== '', fn ($q) => $q->whereRaw('LOWER(number) LIKE ?', ['%'.mb_strtolower(trim($search)).'%']))->latest('id')->limit(100)->pluck('id'); + + return PurchaseOrder::query()->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('id', $selected->filter()->unique()))->latest('id')->get(['id', 'number']); + } + + private function history(Request $request, User $actor, SupplyPlanning $planning): Response { $prepare = $planning->allows($actor, 'supply_history_prepare'); $review = $planning->allows($actor, 'supply_history_review'); abort_unless($prepare || $review || $planning->allows($actor, 'supply_read'), 403); $ready = Schema::hasTable('supply_records'); - $records = $ready ? $planning->latest('history')->latest('id')->limit(100)->get() : collect(); + $focus = $this->focusedRecord($request, $actor, 'history'); + [$records, $page] = $this->recordPage($request, $planning, $ready, 'history', $focus); + $service = app(SupplyHistory::class); - return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'canPrepare' => $prepare, 'canReview' => $review, + return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id, + 'recordPagination' => $this->pagination($page), + 'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id, + 'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $service->canReview($actor, $record), 'canReturn' => $service->canReview($actor, $record, 'return'), 'canSuspend' => $service->canReview($actor, $record, 'suspend')]), 'canPrepare' => $prepare, 'canReview' => $review, 'defaultPeriod' => ['from' => today()->subDays(30)->toDateString(), 'until' => today()->subDay()->toDateString()], 'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'history' => $ready ? SupplyRecord::query()->where('kind', 'history')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [], ]); } - private function suggestions(User $actor, SupplyPlanning $planning): Response + private function suggestions(Request $request, User $actor, SupplyPlanning $planning): Response { $prepare = $planning->allows($actor, 'supply_suggest_prepare'); $review = $planning->allows($actor, 'supply_suggest_review'); @@ -95,10 +227,16 @@ class SupplyPlanningController extends Controller $convert = $convertPermission && app(OrganizationAccess::class)->canRequest($actor); abort_unless($planning->allows($actor, 'supply_read') || $prepare || $review || $convertPermission, 403); $ready = Schema::hasTable('supply_records'); - $records = $ready ? $planning->latest('suggestion')->latest('id')->limit(100)->get() : collect(); + $focus = $this->focusedRecord($request, $actor, 'suggestion'); + [$records, $page] = $this->recordPage($request, $planning, $ready, 'suggestion', $focus); + $filters = $request->validate([ + 'policy_search' => 'nullable|string|max:100', + 'selected_policy_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'policy')], + 'policy_form_key' => 'nullable|string|max:80', + ]); $policies = []; if ($ready && $prepare) { - foreach ($planning->latest('policy')->where('state', 'approved')->latest('id')->limit(100)->get() as $policy) { + foreach ($this->approvedOptions($planning, 'policy', $records->pluck('data.policy_key')->push($filters['selected_policy_key'] ?? null), $filters) as $policy) { $option = ['key' => $policy->record_key, 'version' => $policy->version, 'label' => $policy->snapshot['item']['code'].' · '.$policy->snapshot['item']['name'].' · '.$policy->snapshot['warehouse']['name'], 'issue' => null]; try { $basis = app(SupplySuggestions::class)->basis($policy->record_key, today()->toDateString()); @@ -112,7 +250,16 @@ class SupplyPlanningController extends Controller } $requests = Requisition::query()->visibleTo($actor)->whereIn('id', $records->pluck('data.requisition_id')->filter())->get(['id', 'number'])->keyBy('id'); - return Inertia::render('supply/suggestions', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'policies' => $policies, 'canPrepare' => $prepare, 'canReview' => $review, 'canConvert' => $convert, + $service = app(SupplySuggestions::class); + + return Inertia::render('supply/suggestions', ['ready' => $ready, 'actorId' => $actor->id, + 'policySearch' => $filters['policy_search'] ?? '', 'policyFormKey' => $filters['policy_form_key'] ?? null, + 'recordPagination' => $this->pagination($page), + 'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id, + 'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), + 'canApprove' => $service->canAct($actor, $record, 'approve'), 'canReturn' => $service->canAct($actor, $record, 'return'), + 'canConvert' => $service->canAct($actor, $record, 'convert'), 'canCancel' => $service->canAct($actor, $record, 'cancel'), 'canClose' => $service->canAct($actor, $record, 'close')]), + 'policies' => $policies, 'canPrepare' => $prepare, 'canReview' => $review, 'canConvert' => $convert, 'costCenters' => $convert ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [], 'requestLinks' => $requests, 'excess' => $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplySuggestions::class)->hasExcess($r)]), @@ -120,7 +267,7 @@ class SupplyPlanningController extends Controller ]); } - private function coordination(User $actor, string $view, SupplyPlanning $planning): Response + private function coordination(Request $request, User $actor, string $view, SupplyPlanning $planning): Response { [$kind, $prepare, $review] = match ($view) { 'alternatives' => ['alternative', 'supply_technical_prepare', 'supply_technical_review'], @@ -128,25 +275,79 @@ class SupplyPlanningController extends Controller 'deliveries' => ['delivery', 'supply_delivery', 'supply_delivery'], default => abort(404), }; - abort_unless($planning->allows($actor, 'supply_read') || $planning->allows($actor, $prepare) || $planning->allows($actor, $review), 403); + $read = app(SupplyCoordinationRead::class); + $read->kind($actor, $view); $ready = Schema::hasTable('supply_records'); - $records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect(); + $focus = $this->focusedRecord($request, $actor, $kind); + $request->validate(['records_page' => 'nullable|integer|min:1|max:1000000']); + $filters = $read->filters($request, $kind); + $query = $ready ? ($focus === null ? $read->query($kind, $filters) : $planning->latest($kind))->latest('id') : null; + $page = $focus === null ? $query?->paginate(25, ['*'], 'records_page')->withQueryString() : null; + $records = $focus !== null ? $query?->where('record_key', $focus->record_key)->get() ?? collect() : $page?->getCollection() ?? collect(); $canPrepare = $planning->allows($actor, $prepare); + $lineFilters = in_array($kind, ['reservation', 'delivery'], true) ? $request->validate([ + 'line_search' => 'nullable|string|max:100', 'line_form_key' => 'nullable|string|max:80', + 'selected_order_line_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_order_items', 'id')->where('tenant_id', $actor->tenant_id)], + 'selected_request_line_id' => ['nullable', 'integer', 'min:1', Rule::exists('requisition_items', 'id')->where('tenant_id', $actor->tenant_id)], + ]) : []; + $lineSearch = trim($lineFilters['line_search'] ?? ''); $reversedIds = $ready && $kind === 'reservation' ? array_keys(app(InventoryReversals::class)->corrections('operation')) : []; - $orders = $canPrepare && $kind === 'delivery' ? PurchaseOrder::query()->latest('id')->limit(100)->get()->keyBy('id') : collect(); + $orders = collect(); + if ($canPrepare && $kind === 'delivery') { + $recentOrders = PurchaseOrder::query()->where('status', 'issued')->when($lineSearch !== '', fn ($q) => $q->whereLike('number', '%'.$lineSearch.'%'))->latest('id')->limit(100)->pluck('id'); + $selectedOrders = $records->pluck('purchase_order_id')->filter(); + if (isset($lineFilters['selected_order_line_id'])) { + $selectedOrders->push(PurchaseOrderItem::query()->whereKey($lineFilters['selected_order_line_id'])->value('purchase_order_id')); + } + $orders = PurchaseOrder::query()->where('status', 'issued')->where(fn ($q) => $q->whereIn('id', $recentOrders)->orWhereIn('id', $selectedOrders))->latest('id')->get()->keyBy('id'); + } $orderLines = PurchaseOrderItem::query()->whereIn('purchase_order_id', $orders->keys())->get()->map(fn ($line) => [ 'id' => $line->id, 'label' => $orders[$line->purchase_order_id]->number.' · '.$line->description.' · '.$line->quantity.' '.$line->unit, 'stock_item_id' => $orders[$line->purchase_order_id]->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null, 'quantity_milli' => ReceiptWorkflow::milli($line->quantity), ]); - $requests = $canPrepare && $kind === 'reservation' ? Requisition::query()->where('status', 'approved')->latest('id')->limit(100)->get()->keyBy('id') : collect(); + $requests = collect(); + if ($canPrepare && $kind === 'reservation') { + $recentRequests = Requisition::query()->where('status', 'approved')->when($lineSearch !== '', fn ($q) => $q->whereLike('number', '%'.$lineSearch.'%'))->latest('id')->limit(100)->pluck('id'); + $selectedRequests = RequisitionItem::query()->whereIn('id', $records->pluck('data.requisition_item_id')->push($lineFilters['selected_request_line_id'] ?? null)->filter())->pluck('requisition_id'); + $requests = Requisition::query()->where('status', 'approved')->where(fn ($q) => $q->whereIn('id', $recentRequests)->orWhereIn('id', $selectedRequests))->latest('id')->get()->keyBy('id'); + } $requestLines = RequisitionItem::query()->whereIn('requisition_id', $requests->keys())->get()->filter(fn ($line) => isset($line->catalogue_snapshot['stock_item_id']))->map(fn ($line) => [ 'id' => $line->id, 'label' => $requests[$line->requisition_id]->number.' · '.$line->description, 'stock_item_id' => $line->catalogue_snapshot['stock_item_id'], 'quantity_milli' => ReceiptWorkflow::milli($line->quantity), 'reference' => $requests[$line->requisition_id]->number.' / '.$line->id, 'needed_on' => $requests[$line->requisition_id]->needed_by->toDateString(), ])->values(); - return Inertia::render('supply/coordination', ['kind' => $kind, 'ready' => $ready, 'records' => $records, + $coordination = app(SupplyCoordination::class); + $issueFilters = $kind === 'reservation' ? $request->validate([ + 'issue_search' => 'nullable|string|max:100', + 'issue_record_id' => ['nullable', 'required_with:issue_search,selected_issue_id', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'reservation')], + 'selected_issue_id' => ['nullable', 'integer', 'min:1', Rule::exists('stock_operations', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'issue')], + ]) : []; + $operations = collect(); + if ($ready && $canPrepare && $kind === 'reservation') { + $issueRecord = isset($issueFilters['issue_record_id']) ? SupplyRecord::query()->findOrFail((int) $issueFilters['issue_record_id']) : null; + if ($issueRecord !== null) { + $issueRecord = $planning->latest('reservation')->where('record_key', $issueRecord->record_key)->firstOrFail(); + abort_unless($coordination->canManage($actor, $issueRecord, 'fulfil'), 403); + } + $query = StockOperation::query()->where('kind', 'issue')->whereNotIn('id', $reversedIds) + ->when($issueRecord !== null, fn ($q) => $q->where('stock_item_id', $issueRecord->stock_item_id)->where('warehouse_id', $issueRecord->warehouse_id)); + $search = trim($issueFilters['issue_search'] ?? ''); + $recent = (clone $query)->when($search !== '', fn ($q) => $q->whereLike('reference', '%'.$search.'%'))->latest('id')->limit(100)->pluck('id'); + $operations = $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhere('id', $issueFilters['selected_issue_id'] ?? 0)) + ->latest('id')->get(['id', 'reference', 'stock_item_id', 'warehouse_id', 'quantity_milli']); + } + + return Inertia::render('supply/coordination', ['kind' => $kind, 'ready' => $ready, + 'recordFilters' => ['record_search' => trim($filters['record_search'] ?? ''), 'record_state' => $filters['record_state'] ?? ''], + 'recordPagination' => $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(), + 'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()], + 'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id, + 'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $coordination->canReviewAlternative($actor, $record), + 'canReturn' => $coordination->canReviewAlternative($actor, $record, 'return'), 'canSuspend' => $coordination->canReviewAlternative($actor, $record, 'suspend'), + 'canEdit' => $coordination->canManage($actor, $record, 'edit'), 'canFulfil' => $coordination->canManage($actor, $record, 'fulfil'), + 'canCancel' => $coordination->canManage($actor, $record, 'cancel')]), 'eligible' => $kind === 'alternative' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->eligibleAlternative($r)]) : [], 'pending' => $kind === 'delivery' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingDelivery($r)]) : ($kind === 'reservation' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingReservation($r)]) : []), 'reversedOperations' => $reversedIds, @@ -154,7 +355,9 @@ class SupplyPlanningController extends Controller 'items' => $canPrepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $canPrepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'orderLines' => $orderLines, 'requestLines' => $requestLines, - 'operations' => $canPrepare && $kind === 'reservation' ? StockOperation::query()->where('kind', 'issue')->whereNotIn('id', $reversedIds)->latest('id')->limit(100)->get(['id', 'reference', 'stock_item_id', 'warehouse_id', 'quantity_milli']) : [], + 'lineSearch' => $lineSearch, 'lineFormKey' => $lineFilters['line_form_key'] ?? null, + 'operations' => $operations, 'issueSearch' => $issueFilters['issue_search'] ?? '', + 'issueRecordId' => isset($issueFilters['issue_record_id']) ? (int) $issueFilters['issue_record_id'] : null, 'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [], ]); } diff --git a/app/Http/Controllers/WorkflowNoticeController.php b/app/Http/Controllers/WorkflowNoticeController.php index 14f340a..392beec 100644 --- a/app/Http/Controllers/WorkflowNoticeController.php +++ b/app/Http/Controllers/WorkflowNoticeController.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Services\WorkflowNotices; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Inertia\Inertia; use Symfony\Component\HttpFoundation\Response; @@ -13,18 +14,41 @@ use Symfony\Component\HttpFoundation\Response; class WorkflowNoticeController extends Controller { public function index(Request $request, WorkflowNotices $notices): Response + { + return DB::transaction(function () use ($request, $notices) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + + return $this->page($request, $notices); + }); + } + + /** @return array */ + private function filters(Request $request): array + { + return $request->validate(['view' => 'nullable|in:all,unread,tasks,outcomes', 'page' => 'nullable|integer|min:1|max:100000', 'search' => 'nullable|string|max:100']); + } + + private function page(Request $request, WorkflowNotices $notices): Response { $actor = $request->user('web'); abort_unless($actor instanceof User, 403); - $filter = $request->validate(['view' => 'nullable|in:all,unread,tasks,outcomes', 'page' => 'nullable|integer|min:1|max:100000']); + $filter = $this->filters($request); $visible = collect($notices->visible($actor)); $unread = $visible->whereNull('read_at')->count(); $items = ($filter['view'] ?? 'all') === 'unread' ? $visible->whereNull('read_at')->values() : $visible; if (in_array($filter['view'] ?? '', ['tasks', 'outcomes'], true)) { $items = $visible->where('kind', $filter['view'] === 'tasks' ? 'task' : 'outcome')->values(); } - $page = (int) ($filter['page'] ?? 1); + $search = trim($filter['search'] ?? ''); + if ($search !== '') { + $items = $items->filter(fn ($notice) => mb_stripos($notice['reference'], $search) !== false)->values(); + } + $page = min((int) ($filter['page'] ?? 1), max(1, (int) ceil($items->count() / 25))); + Inertia::encryptHistory(); $response = Inertia::render('workflow/notices', ['ready' => Schema::hasTable('workflow_notices'), 'unread' => $unread, 'view' => $filter['view'] ?? 'all', + 'search' => $search, 'notices' => (new LengthAwarePaginator($items->slice(($page - 1) * 25, 25)->values(), $items->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request); $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); @@ -35,9 +59,10 @@ class WorkflowNoticeController extends Controller { $actor = $request->user('web'); abort_unless($actor instanceof User, 403); + $filters = $this->filters($request); $notices->read($actor, $notice); - return redirect('/requisicoes/avisos'); + return redirect('/requisicoes/avisos'.($filters === [] ? '' : '?'.http_build_query($filters))); } public function refresh(Request $request, int $requisition, WorkflowNotices $notices): Response diff --git a/app/Http/Controllers/WorkflowTaskInboxController.php b/app/Http/Controllers/WorkflowTaskInboxController.php index af76d58..653b49c 100644 --- a/app/Http/Controllers/WorkflowTaskInboxController.php +++ b/app/Http/Controllers/WorkflowTaskInboxController.php @@ -29,23 +29,38 @@ class WorkflowTaskInboxController extends Controller 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']); + 'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:1000000', + 'due' => 'nullable|in:all,overdue,today,upcoming,undated']); 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)); + $today = today()->toDateString(); + $all = collect($inbox->build($actor, $area))->map(function ($row) use ($today) { + $date = $row['due_on'] ?? null; + $row['due_status'] = $date === null ? 'undated' : ($date < $today ? 'overdue' : ($date === $today ? 'today' : 'upcoming')); + + return $row; + }); $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(); + $deadlineCounts = []; + foreach (['overdue', 'today', 'upcoming', 'undated'] as $status) { + $deadlineCounts[$status] = $rows->where('due_status', $status)->count(); + } + if (($filters['due'] ?? 'all') !== 'all') { + $rows = $rows->where('due_status', $filters['due'])->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'] ?? ''], + 'checkedAt' => now()->toIso8601String(), 'asOf' => $today, 'deadlineCounts' => $deadlineCounts, + 'filters' => ['module' => $filters['module'] ?? 'all', 'search' => $filters['search'] ?? '', 'due' => $filters['due'] ?? 'all'], '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'); diff --git a/app/Http/Middleware/PrivateTwoFactorResponse.php b/app/Http/Middleware/PrivateTwoFactorResponse.php new file mode 100644 index 0000000..70da2fc --- /dev/null +++ b/app/Http/Middleware/PrivateTwoFactorResponse.php @@ -0,0 +1,34 @@ +route()?->getName() ?? '', 'two-factor.')) { + return $next($request); + } + try { + $response = $next($request); + } catch (ValidationException $error) { + // Exclude authentication material from old input without affecting other forms' code fields. + $request->request->remove('code'); + $request->request->remove('recovery_code'); + $request->json()->remove('code'); + $request->json()->remove('recovery_code'); + throw $error; + } + if ($request->hasSession()) { + $request->session()->forget(['_old_input.code', '_old_input.recovery_code']); + } + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } +} diff --git a/app/Http/Requests/SaveSupplierRequest.php b/app/Http/Requests/SaveSupplierRequest.php index bad83da..2c0d23d 100644 --- a/app/Http/Requests/SaveSupplierRequest.php +++ b/app/Http/Requests/SaveSupplierRequest.php @@ -2,8 +2,9 @@ namespace App\Http\Requests; -use App\Enums\PurchasingRole; use App\Models\Supplier; +use App\Models\User; +use App\Services\SupplierParticipation; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,7 +12,9 @@ class SaveSupplierRequest extends FormRequest { public function authorize(): bool { - return $this->user()?->hasFunctions([PurchasingRole::Procurement]) ?? false; + $actor = $this->user('web'); + + return $actor instanceof User && app(SupplierParticipation::class)->canPrepare($actor); } /** @return array */ diff --git a/app/Models/ReceiptDocument.php b/app/Models/ReceiptDocument.php new file mode 100644 index 0000000..d81fa9a --- /dev/null +++ b/app/Models/ReceiptDocument.php @@ -0,0 +1,29 @@ + 'datetime']; + } +} diff --git a/app/Models/SupplierResponse.php b/app/Models/SupplierResponse.php index dd0466b..8226927 100644 --- a/app/Models/SupplierResponse.php +++ b/app/Models/SupplierResponse.php @@ -3,7 +3,9 @@ namespace App\Models; use App\Models\Concerns\BelongsToTenant; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int $id @@ -29,6 +31,18 @@ class SupplierResponse extends Model return ['payload' => 'array', 'contributors' => 'array', 'decision' => 'array', 'reviewed_at' => 'datetime']; } + /** @param Builder $query */ + public function scopeVisibleTo(Builder $query, User $actor): void + { + $query->where('tenant_id', $actor->tenant_id)->whereIn('supplier_evaluation_id', SupplierEvaluation::query()->visibleTo($actor)->select('id')); + } + + /** @return BelongsTo */ + public function evaluation(): BelongsTo + { + return $this->belongsTo(SupplierEvaluation::class, 'supplier_evaluation_id'); + } + protected static function booted(): void { static::updating(function (self $record) { diff --git a/app/Models/User.php b/app/Models/User.php index b1ec8de..71e9f12 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; +use Laravel\Fortify\TwoFactorAuthenticatable; /** * @property int $id @@ -44,7 +45,7 @@ class User extends Authenticatable } /** @use HasFactory */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. @@ -56,6 +57,7 @@ class User extends Authenticatable return [ 'purchasing_role' => PurchasingRole::class, 'email_verified_at' => 'datetime', + 'two_factor_confirmed_at' => 'datetime', 'password' => 'hashed', ]; } diff --git a/app/Policies/SupplierPolicy.php b/app/Policies/SupplierPolicy.php index b005067..904cf0c 100644 --- a/app/Policies/SupplierPolicy.php +++ b/app/Policies/SupplierPolicy.php @@ -5,6 +5,7 @@ namespace App\Policies; use App\Enums\PurchasingRole; use App\Models\Supplier; use App\Models\User; +use App\Services\SupplierParticipation; class SupplierPolicy { @@ -20,7 +21,7 @@ class SupplierPolicy public function update(User $user, Supplier $supplier): bool { - return $user->hasFunctions([PurchasingRole::Procurement]) && $user->tenant_id === $supplier->tenant_id; + return app(SupplierParticipation::class)->canPrepare($user) && $user->tenant_id === $supplier->tenant_id; } public function viewDocuments(User $user, Supplier $supplier): bool diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 20f2360..0a24232 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -19,6 +19,7 @@ class AppServiceProvider extends ServiceProvider require_once base_path('packages/purchasing/bootstrap.php'); $package = config('common-budget.path'); require_once $package.'/bootstrap.php'; + \App\Services\FinanceBundle::configure(); $payables = config('common-payables.path'); require_once $payables.'/bootstrap.php'; } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 71181a9..0c7f187 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -68,6 +68,22 @@ class FortifyServiceProvider extends ServiceProvider 'passwordRules' => Password::defaults()->toPasswordRulesString(), ])); + Fortify::twoFactorChallengeView(function () { + Inertia::encryptHistory(); + $response = Inertia::render('auth/two-factor-challenge')->toResponse(request()); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + }); + Fortify::confirmPasswordView(function () { + Inertia::encryptHistory(); + + $response = Inertia::render('auth/confirm-password')->toResponse(request()); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + }); + } /** @@ -82,5 +98,7 @@ class FortifyServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($throttleKey); }); + RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by((string) $request->session()->get('login.id').'|'.$request->ip())); + } } diff --git a/app/Services/AutomaticPurchases.php b/app/Services/AutomaticPurchases.php index d0e9a42..ec4f91e 100644 --- a/app/Services/AutomaticPurchases.php +++ b/app/Services/AutomaticPurchases.php @@ -29,7 +29,7 @@ class AutomaticPurchases 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) + if (! $this->ready() || $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; } diff --git a/app/Services/FinanceBundle.php b/app/Services/FinanceBundle.php new file mode 100644 index 0000000..44012ee --- /dev/null +++ b/app/Services/FinanceBundle.php @@ -0,0 +1,50 @@ +environment(['testing', 'payable-testing']) || ! is_string($root) || ! is_string($pin) || ! preg_match('/^[a-f0-9]{64}$/D', $pin)) { + throw new \LogicException('FINANCE_BUNDLE_QA_ONLY'); + } + $db = DB::connection(); + $r = $db->selectOne("SELECT current_database() d,current_user u,inet_server_port() p,current_setting('cluster_name') c"); + if ($db->getConfig('host') !== '127.0.0.1' || (int) $r->p !== 55439 || ! preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D', $r->d, $m) || $r->u !== 'payable_history_native_'.$m[1] || ! preg_match('/^sgf_modular_regression_[a-f0-9]{12}$/D', $r->c)) { + throw new \LogicException('FINANCE_BUNDLE_DATABASE_REFUSED'); + } + if ($db->selectOne('SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user')->elevated) { + throw new \LogicException('FINANCE_BUNDLE_ELEVATED_ROLE'); + } + $root = realpath($root) ?: throw new \LogicException('FINANCE_BUNDLE_MISSING'); + $manifest = $root.'/manifest.json'; + $bytes = is_file($manifest) && ! is_link($manifest) ? file_get_contents($manifest) : false; + if (! is_string($bytes) || ! hash_equals($pin, hash('sha256', $bytes))) { + throw new \LogicException('FINANCE_BUNDLE_MANIFEST_MISMATCH'); + } + $m = json_decode($bytes, true, 64, JSON_THROW_ON_ERROR); + $loader = null; + foreach ($m['files'] ?? [] as $f) { + if (($f['file'] ?? null) === 'Bundle.php') { + $loader = $f['sha256'] ?? null; + } + } + $loaderHash = is_file($root.'/Bundle.php') && ! is_link($root.'/Bundle.php') ? hash_file('sha256', $root.'/Bundle.php') : false; + if (! is_string($loader) || ! is_string($loaderHash) || ! hash_equals($loader, $loaderHash)) { + throw new \LogicException('FINANCE_BUNDLE_LOADER_MISMATCH'); + } + require_once $root.'/Bundle.php'; + $paths = Bundle::verify($root, $pin); + config(['common-payables.path' => $paths['payables'], 'general-ledger.path' => $paths['ledger']]); + } +} diff --git a/app/Services/GeneralLedger.php b/app/Services/GeneralLedger.php new file mode 100644 index 0000000..e04d8a7 --- /dev/null +++ b/app/Services/GeneralLedger.php @@ -0,0 +1,132 @@ +,person:string} */ + private function context(User $user): array + { + abort_unless(app()->environment('payable-testing') && config('general-ledger.synthetic_enabled') === true, 403); + $package = config('general-ledger.path'); + abort_unless(is_string($package) && is_file($package.'/bootstrap.php'), 503, 'Pacote contabilístico não composto.'); + require_once $package.'/bootstrap.php'; + LedgerSchema::assertSynthetic(DB::connection()); + $context = app(SupplierPayables::class)->readContext($user); + abort_unless(DB::table('pa_access')->where($context['scope'])->where(['user_id' => $user->id, 'action' => 'general_ledger.view', 'person' => $context['person']])->exists(), 403); + + return $context; + } + + public function can(User $user, string $action): bool + { + $c = $this->context($user); + + return DB::table('pa_access')->where($c['scope'])->where(['user_id' => $user->id, 'action' => 'general_ledger.'.$action, 'person' => $c['person']])->exists(); + } + + public function engine(User $user, ?\Closure $fault = null): PayableJournal + { + $c = $this->context($user); + $scope = $c['scope']; + $person = $c['person']; + $authorize = function (string $action, bool $lock = true) use ($user, $scope, $person) { + if ($lock) { + app(ConflictGuard::class)->lockCompany($user); + app(CurrentActor::class)->lock($user, [PurchasingRole::Finance, PurchasingRole::Admin]); + } + $fresh = $this->context($user); + abort_unless($fresh['scope'] === $scope && $fresh['person'] === $person, 409, 'Pessoa ou contexto alterados.'); + abort_unless($this->can($user, $action), 403); + }; + + return new PayableJournal(DB::connection(), $scope, $user->id, $person, fn ($a) => $authorize($a), fn () => $authorize('view', false), fn ($id) => $this->source($user, $scope, $id), $fault); + } + + /** @param array $scope + * @return array + */ + private function source(User $user, array $scope, int $id): array + { + $o = DB::table('pa_obligations')->where($scope)->where('id', $id)->lockForUpdate()->first() ?? throw new \DomainException('Obrigação indisponível nesta organização.'); + $origin = DB::table('pa_sources')->where($scope)->where('id', $o->source_id)->lockForUpdate()->first() ?? throw new \DomainException('Origem ausente.'); + $finance = app(SupplierPayables::class); + $doc = $finance->document($user, (int) $origin->local_source_id); + $position = $finance->engine($user)->positionForSource((int) $origin->local_source_id); + if (! $position || (int) $position['id'] !== $id || $position['source_conflict']) { + throw new \DomainException('Origem financeira mudou; nova conferência necessária.'); + } + $p = DB::table('pa_proposals')->where($scope)->where('id', $o->proposal_id)->first() ?? throw new \DomainException('Conferência original ausente.'); + + return ['effect_type' => 'supplier_payable.recognised', 'origin_key' => $origin->source_key, 'obligation_id' => $id, 'local_source_id' => (int) $origin->local_source_id, 'document_revision' => $o->source_revision, 'recognition_revision' => hash('sha256', Payables::canonical(['obligation' => (array) $o, 'recognition_snapshot' => $p->snapshot])), 'principal_minor' => (int) $o->principal_minor, 'currency' => $o->currency, 'due_on' => $o->due_on, 'tax_minor' => $doc['tax_minor'], 'compatible' => true, 'history_imported' => $position['history_imported']]; + } + + /** @param array $options + * @return array + */ + public function report(User $user, string $kind, array $options): array + { + if (! in_array($kind, ['trial-balance', 'account-ledger'], true)) { + throw new \InvalidArgumentException('Relatório desconhecido.'); + } + $own = DB::transactionLevel() === 0; + + return DB::transaction(function () use ($user, $kind, $options, $own) { + if ($own) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + $engine = $this->engine($user); + abort_unless(class_exists(ReportsWorkspace::class), 503, 'Relatórios não incluídos no pacote composto.'); + foreach (LedgerSchema::TABLES as $table) { + abort_unless(DB::getSchemaBuilder()->hasTable($table), 503, 'Fonte contabilística indisponível neste ensaio.'); + } + + return $kind === 'trial-balance' ? $engine->trialBalance($options) : $engine->accountLedger($options); + }); + } + + /** @return array */ + public function workspace(User $user, int $page = 1): array + { + abort_unless(config('general-ledger.cycle_enabled') === true, 403); + $own = DB::transactionLevel() === 0; + + return DB::transaction(function () use ($user, $page, $own) { + if ($own) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + $engine = $this->engine($user); + $data = $engine->cycleListing($page); + $data['book'] = $engine->listing(['page' => $page]); + $actions = ['prepare', 'validate', 'post', 'correct_prepare', 'correct_validate', 'correct_post', 'period_prepare', 'period_validate', 'period_close']; + $data['permissions'] = array_combine($actions, array_map(fn ($a) => $this->can($user, $a), $actions)); + + return $data; + }); + } + + /** @param array $options + * @return array + */ + public function listing(User $user, array $options = []): array + { + $own = DB::transactionLevel() === 0; + + return DB::transaction(function () use ($user, $options, $own) { + if ($own) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + + return $this->engine($user)->listing($options); + }); + } +} diff --git a/app/Services/InventoryCorrectionPage.php b/app/Services/InventoryCorrectionPage.php new file mode 100644 index 0000000..a74ded6 --- /dev/null +++ b/app/Services/InventoryCorrectionPage.php @@ -0,0 +1,85 @@ +user(); + abort_unless($actor instanceof User && $actor->tenant_id !== null, 403); + + return User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id); + } + + /** @param Collection $selected + * @return Collection + */ + public function receipts(Collection $selected, string $search = ''): Collection + { + $actor = $this->actor(); + abort_unless(app(InventoryDispositions::class)->allows($actor, 'prepare') || app(InventoryReversals::class)->allows($actor, 'prepare'), 403); + $query = GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0) + ->whereNotIn('id', array_keys(app(InventoryReversals::class)->corrections('receipt'))); + $recent = (clone $query)->when(trim($search) !== '', fn ($q) => $q->whereIn('goods_receipt_id', GoodsReceipt::query()->whereLike('number', '%'.trim($search).'%')->select('id')))->latest('id')->limit(200)->pluck('id'); + + return $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('id', $selected->filter()->unique()))->latest('id')->get(); + } + + /** @param Collection $selected + * @return Collection + */ + public function operations(Collection $selected, string $search = ''): Collection + { + abort_unless(app(InventoryReversals::class)->allows($this->actor(), 'prepare'), 403); + $query = StockOperation::query()->whereIn('kind', ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off']) + ->whereNotIn('id', array_keys(app(InventoryReversals::class)->corrections('operation'))); + $recent = (clone $query)->when(trim($search) !== '', fn ($q) => $q->whereLike('reference', '%'.trim($search).'%'))->latest('id')->limit(200)->pluck('id'); + + return $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('id', $selected->filter()->unique()))->latest('id')->get(); + } + + /** @return array{ready:bool,focus:SupplyRecord|null,records:Collection,filters:array{search:string,state:string},lookup:array,pagination:array|null} */ + public function read(Request $request, User $actor, string $kind): array + { + abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null && in_array($kind, ['stock_event', 'stock_reversal'], true), 403); + $actor = $this->actor(); + $workflow = $kind === 'stock_event' ? app(InventoryDispositions::class) : app(InventoryReversals::class); + abort_unless(collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($capability) => $workflow->allows($actor, $capability)), 403); + $filters = $request->validate([ + 'record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)], + 'records_page' => 'nullable|integer|min:1|max:1000000', + 'search' => 'nullable|string|max:100', + 'state' => 'nullable|in:draft,returned,approved,executed', + 'source_search' => 'nullable|string|max:100', 'source_form_key' => 'nullable|string|max:80', + 'selected_receipt_id' => ['nullable', 'integer', 'min:1', Rule::exists('goods_receipt_items', 'id')->where('tenant_id', $actor->tenant_id)], + 'selected_operation_id' => ['nullable', 'integer', 'min:1', Rule::exists('stock_operations', 'id')->where('tenant_id', $actor->tenant_id)], + ]); + $ready = Schema::hasTable('supply_records'); + $focus = isset($filters['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', $kind)->findOrFail((int) $filters['record_id']) : null; + $query = $ready ? app(SupplyPlanning::class)->latest($kind)->where('tenant_id', $actor->tenant_id)->latest('id') : null; + $search = trim($filters['search'] ?? ''); + $state = $filters['state'] ?? ''; + $page = $focus === null ? $query?->when($search !== '', fn ($q) => $q->whereLike('data->reference', '%'.$search.'%')) + ->when($state !== '', fn ($q) => $q->where('state', $state))->paginate(25, ['*'], 'records_page')->withQueryString() : null; + + return ['ready' => $ready, 'focus' => $focus, + 'lookup' => ['search' => trim($filters['source_search'] ?? ''), 'form' => $filters['source_form_key'] ?? null, + 'receipt' => $filters['selected_receipt_id'] ?? null, 'operation' => $filters['selected_operation_id'] ?? null], + 'records' => $focus !== null ? $query?->where('record_key', $focus->record_key)->get() ?? collect() : $page?->getCollection() ?? collect(), + 'filters' => ['search' => $search, 'state' => $state], + 'pagination' => $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(), + 'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()]]; + } +} diff --git a/app/Services/InventoryDispositions.php b/app/Services/InventoryDispositions.php index a6d613e..251b0c7 100644 --- a/app/Services/InventoryDispositions.php +++ b/app/Services/InventoryDispositions.php @@ -20,6 +20,17 @@ use Illuminate\Validation\ValidationException; /** Physical supplier returns and write-offs; financial corrections remain distinct. */ class InventoryDispositions { + public function executionSourceCurrent(SupplyRecord $record): bool + { + return $record->kind === 'stock_event' && app(InventoryReviewParticipation::class)->executionSourceCurrent($record, fn (array $data) => $this->basis($data)); + } + + /** Participation only: source, stock and physical evidence are revalidated by save. */ + public function canAct(User $actor, SupplyRecord $record, string $action): bool + { + return app(InventoryReviewParticipation::class)->canAct($actor, $record, 'stock_event', $action, fn (string $capability) => $this->allows($actor, $capability)); + } + public function allows(User $actor, string $capability): bool { return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($capability) { @@ -163,7 +174,7 @@ class InventoryDispositions $data = $old->data; $snapshot = $old->snapshot; if ($v['action'] === 'execute') { - $this->check($old->state === 'approved' && $actor->id !== $snapshot['reviewed_by'], 'action', 'A saída exige aprovação prévia e operador diferente de quem aprovou.'); + $this->check($old->state === 'approved' && app(InventoryReviewParticipation::class)->independentExecutor($actor, $old), 'action', 'A saída exige aprovação prévia e operador diferente de quem aprovou.'); $event = Validator::make($v['data'], ['event_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:10|max:3000'])->validate(); $this->check($event['event_on'] >= $old->created_at->toDateString(), 'data.event_on', 'A saída não pode anteceder a aprovação.'); $current = $this->basis($data); @@ -174,7 +185,7 @@ class InventoryDispositions $snapshot['execution_observed'] = $current['observed']; $state = 'executed'; } else { - $this->check(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A decisão exige pessoa independente de todos os preparadores.'); + $this->check(app(InventoryReviewParticipation::class)->independentReviewer($actor, $old), 'action', 'A decisão exige pessoa independente de todos os preparadores.'); $this->check($v['action'] === 'return' ? in_array($old->state, ['draft', 'returned', 'approved'], true) : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível com a decisão.'); $state = $v['action'] === 'approve' ? 'approved' : 'returned'; if ($state === 'approved') { diff --git a/app/Services/InventoryLots.php b/app/Services/InventoryLots.php index bd36153..09310ef 100644 --- a/app/Services/InventoryLots.php +++ b/app/Services/InventoryLots.php @@ -21,6 +21,46 @@ use Illuminate\Validation\ValidationException; /** Lot quantities partition the physical ledger; they never create physical stock. */ class InventoryLots { + public function canIdentify(User $actor, ?SupplyRecord $metadata): bool + { + return $metadata !== null && $metadata->kind === 'lot' && $metadata->tenant_id === $actor->tenant_id + && $this->allows($actor, 'inventory_lot_prepare') + && app(SupplyPlanning::class)->latest('lot')->where('record_key', $metadata->record_key)->value('id') === $metadata->id; + } + + /** @param array $context + * @param array $entry + */ + public function canDecideLot(User $actor, ?SupplyRecord $metadata, array $context, array $entry, string $action): bool + { + if ($metadata === null || $metadata->tenant_id !== $actor->tenant_id || $metadata->kind !== 'lot' + || ($entry['metadata_id'] ?? null) !== $metadata->id || ! ($context['reconciled'] ?? false) + || ($entry['quantity_milli'] ?? 0) <= 0 || ! in_array($action, ['hold', 'release'], true) + || ! $this->allows($actor, 'inventory_lot_'.$action) + || app(SupplyPlanning::class)->latest('lot')->where('record_key', $metadata->record_key)->value('id') !== $metadata->id) { + return false; + } + + return $action === 'hold' || (($entry['eligible'] ?? false) && ! in_array($actor->id, $entry['contributors'], true)); + } + + private function independentSetupReviewer(User $actor, SupplyRecord $record): bool + { + return ! in_array($actor->id, $record->snapshot['contributors'], true); + } + + /** Participation only: activation still checks balance, active setup and metadata stamps. */ + public function canActivate(User $actor, SupplyRecord $record): bool + { + if ($record->kind !== 'lot_setup' || $record->state !== 'draft' || $record->tenant_id !== $actor->tenant_id + || ! $this->allows($actor, 'inventory_lot_review') || ! $this->independentSetupReviewer($actor, $record)) { + return false; + } + $latest = app(SupplyPlanning::class)->latest('lot_setup')->where('record_key', $record->record_key)->first(); + + return $latest?->id === $record->id && $this->setup($record->stock_item_id, $record->warehouse_id)?->id === ($record->snapshot['previous_setup_id'] ?? null); + } + public function allows(User $actor, string $permission): bool { return $actor->hasFunctions($permission === 'inventory_lot_read' ? [$permission, 'admin'] : [$permission]) @@ -167,7 +207,7 @@ class InventoryLots } if ($v['action'] === 'activate') { $this->check($old?->kind === 'lot_setup' && $old->state === 'draft', 'action', 'Prepare primeiro a conferência do saldo.'); - $this->check(! in_array($actor->id, $old->snapshot['contributors'], true), 'action', 'A activação exige conferência independente da preparação.'); + $this->check($this->independentSetupReviewer($actor, $old), 'action', 'A activação exige conferência independente da preparação.'); $active = $this->setup($old->stock_item_id, $old->warehouse_id); $this->check(($active?->id) === ($old->snapshot['previous_setup_id'] ?? null), 'action', 'A configuração activa mudou desde a preparação. Actualize a revisão.'); $this->check(app(StockWorkflow::class)->balance($old->stock_item_id, $old->warehouse_id) === $old->snapshot['balance'], 'action', 'O saldo mudou. Actualize a preparação e confira novamente.'); @@ -186,6 +226,7 @@ class InventoryLots } if ($v['action'] === 'identify') { $this->check($old?->kind === 'lot', 'action', 'Seleccione a identificação existente.'); + $this->check($this->canIdentify($actor, $old), 'action', 'A identificação exige competência e versão actual.'); $metadata = $this->metadata($v['data']); // A correction invalidates the previous quality release by metadata version. @@ -199,6 +240,7 @@ class InventoryLots $this->check($entry !== null && $context['reconciled'] && $entry['quantity_milli'] > 0, 'action', 'Lote sem saldo conferido neste armazém.'); $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $d['warehouse_id'])->first(); $this->check((int) $d['state_version'] === ($state->version ?? 0) && $old?->id === $entry['metadata_id'], 'version', 'A identificação ou a decisão mudou. Actualize a página.'); + $this->check($this->canDecideLot($actor, $old, $context, $entry, $v['action']), 'action', 'A decisão exige saldo conferido, competência, identificação válida e independência na libertação.'); if ($v['action'] === 'release') { $this->check(! in_array($actor->id, $entry['contributors'], true), 'action', 'A libertação exige pessoa independente da identificação e recepção.'); $this->check($entry['eligible'], 'action', 'Confirme identificação e validade suficiente antes de libertar.'); diff --git a/app/Services/InventoryReversals.php b/app/Services/InventoryReversals.php index 66d6d59..d119852 100644 --- a/app/Services/InventoryReversals.php +++ b/app/Services/InventoryReversals.php @@ -23,6 +23,16 @@ use Illuminate\Validation\ValidationException; /** Full corrective reversals; original documents and financial facts remain immutable. */ class InventoryReversals { + public function executionSourceCurrent(SupplyRecord $record): bool + { + return $record->kind === 'stock_reversal' && app(InventoryReviewParticipation::class)->executionSourceCurrent($record, fn (array $data) => $this->basis($data)); + } + + public function canAct(User $actor, SupplyRecord $record, string $action): bool + { + return app(InventoryReviewParticipation::class)->canAct($actor, $record, 'stock_reversal', $action, fn (string $capability) => $this->allows($actor, $capability)); + } + public function allows(User $actor, string $action): bool { return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($action) { @@ -91,8 +101,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.'); + $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(); @@ -198,7 +208,7 @@ class InventoryReversals $data = $old->data; $snapshot = $old->snapshot; if ($v['action'] === 'execute') { - $this->check($old->state === 'approved' && $actor->id !== $snapshot['reviewed_by'], 'action', 'Exige aprovação actual e execução por pessoa diferente de quem aprovou.'); + $this->check($old->state === 'approved' && app(InventoryReviewParticipation::class)->independentExecutor($actor, $old), 'action', 'Exige aprovação actual e execução por pessoa diferente de quem aprovou.'); $event = Validator::make($v['data'], ['event_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:10|max:3000'])->validate(); $this->check($event['event_on'] >= $old->created_at->toDateString(), 'data.event_on', 'O estorno não pode anteceder a aprovação.'); $basis = $this->basis($data); @@ -210,7 +220,7 @@ class InventoryReversals $snapshot['execution_observed'] = $basis['observed']; $state = 'executed'; } else { - $this->check(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A decisão exige pessoa independente de todos os preparadores.'); + $this->check(app(InventoryReviewParticipation::class)->independentReviewer($actor, $old), 'action', 'A decisão exige pessoa independente de todos os preparadores.'); $this->check($v['action'] === 'return' ? in_array($old->state, ['draft', 'returned', 'approved'], true) : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.'); $state = $v['action'] === 'approve' ? 'approved' : 'returned'; if ($state === 'approved') { diff --git a/app/Services/InventoryReviewParticipation.php b/app/Services/InventoryReviewParticipation.php new file mode 100644 index 0000000..72b19a6 --- /dev/null +++ b/app/Services/InventoryReviewParticipation.php @@ -0,0 +1,53 @@ +):array $basis */ + public function executionSourceCurrent(SupplyRecord $record, Closure $basis): bool + { + if ($record->state !== 'approved' || ! isset($record->snapshot['source'])) { + return false; + } + try { + return $basis($record->data)['source'] === $record->snapshot['source']; + } catch (ValidationException|ModelNotFoundException) { + return false; + } + } + + public function independentReviewer(User $actor, SupplyRecord $record): bool + { + return ! in_array($actor->id, $record->snapshot['contributors'], true); + } + + public function independentExecutor(User $actor, SupplyRecord $record): bool + { + return isset($record->snapshot['reviewed_by']) && $actor->id !== $record->snapshot['reviewed_by']; + } + + /** @param Closure(string):bool $allows */ + public function canAct(User $actor, SupplyRecord $record, string $kind, string $action, Closure $allows): bool + { + if (! in_array($kind, ['stock_event', 'stock_reversal'], true) || $record->kind !== $kind || $record->tenant_id !== $actor->tenant_id + || ! in_array($action, ['approve', 'return', 'execute'], true) || ! $allows($action === 'execute' ? 'execute' : 'review')) { + return false; + } + $latest = app(SupplyPlanning::class)->latest($kind)->where('record_key', $record->record_key)->first(); + if ($latest?->id !== $record->id) { + return false; + } + + return $action === 'execute' + ? $record->state === 'approved' && $this->independentExecutor($actor, $record) + : $this->independentReviewer($actor, $record) && in_array($record->state, $action === 'return' ? ['draft', 'returned', 'approved'] : ['draft', 'returned'], true); + } +} diff --git a/app/Services/ReceiptDocuments.php b/app/Services/ReceiptDocuments.php new file mode 100644 index 0000000..fbbfc6b --- /dev/null +++ b/app/Services/ReceiptDocuments.php @@ -0,0 +1,101 @@ +find($receipt->purchase_order_id); + + return $order !== null && ! app(OrganizationAccess::class)->maintenanceOnly($actor) + && Gate::forUser($actor)->allows('view', $receipt) && Gate::forUser($actor)->allows('create', [GoodsReceipt::class, $order]); + } + + public function canRead(User $actor, GoodsReceipt $receipt): bool + { + return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && Gate::forUser($actor)->allows('view', $receipt); + } + + /** @param array $input */ + public function add(User $actor, GoodsReceipt $receipt, array $input, UploadedFile $file): ReceiptDocument + { + abort_unless($this->ready(), 503, 'Anexos indisponíveis nesta instalação.'); + $data = Validator::make([...$input, 'file' => $file], [ + 'operation_key' => 'required|uuid', 'document_type' => 'required|in:delivery_note,quality,other', + 'file' => 'required|file|mimes:pdf,jpg,jpeg,png|extensions:pdf,jpg,jpeg,png|max:2048', + ])->validate(); + $data['operation_key'] = Str::lower($data['operation_key']); + $sha = hash_file('sha256', $file->getPathname()); + abort_unless(is_string($sha) && $file->getSize() > 0, 422); + $name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180); + $hash = hash('sha256', json_encode([$receipt->id, $data['document_type'], $sha, $name], JSON_THROW_ON_ERROR)); + $newPath = null; + try { + return DB::transaction(function () use ($actor, $receipt, $data, $file, $sha, $hash, $name, &$newPath) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $receipt = GoodsReceipt::query()->lockForUpdate()->findOrFail($receipt->id); + abort_unless($this->canAdd($actor, $receipt), 403); + $old = AuditLog::query()->whereIn('action', ['receipt.document_added', 'receipt.document_reused'])->where('after->operation_key', $data['operation_key'])->first(); + if ($old !== null) { + if ($old->user_id !== $actor->id || ($old->after['input_hash'] ?? null) !== $hash) { + throw ValidationException::withMessages(['operation_key' => 'Chave utilizada com outro documento.']); + } + + $documentId = $old->after['document_id'] ?? null; + abort_unless(is_int($documentId), 409, 'Referência documental inválida.'); + + return ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->findOrFail($documentId); + } + $document = ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->where('document_type', $data['document_type'])->where('sha256', $sha)->first(); + $reused = $document !== null; + if ($document === null) { + $newPath = $file->store($actor->tenant_id.'/'.$receipt->id, 'receipt_documents'); + abort_unless(is_string($newPath), 500); + $document = ReceiptDocument::query()->create(['tenant_id' => $actor->tenant_id, 'goods_receipt_id' => $receipt->id, + 'uploaded_by' => $actor->id, 'document_type' => $data['document_type'], 'original_name' => $name, + 'path' => $newPath, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'sha256' => $sha, 'created_at' => now()]); + } + $order = PurchaseOrder::query()->findOrFail($receipt->purchase_order_id); + app(RequisitionWorkflow::class)->audit($actor, $reused ? 'receipt.document_reused' : 'receipt.document_added', Requisition::query()->findOrFail($order->requisition_id), null, + ['receipt_id' => $receipt->id, 'document_id' => $document->id, 'operation_key' => $data['operation_key'], 'input_hash' => $hash]); + + return $document; + }); + } catch (Throwable $error) { + if (is_string($newPath)) { + // Never delete a file whose metadata may already have committed. + try { + if (! DB::table('receipt_documents')->where('path', $newPath)->exists()) { + Storage::disk('receipt_documents')->delete($newPath); + } + } catch (Throwable $cleanupError) { + report($cleanupError); + } + } + throw $error; + } + } +} diff --git a/app/Services/ReplenishmentHeartbeat.php b/app/Services/ReplenishmentHeartbeat.php new file mode 100644 index 0000000..e2a7f2b --- /dev/null +++ b/app/Services/ReplenishmentHeartbeat.php @@ -0,0 +1,30 @@ +getDriverName(), + $connection->getConfig('host'), $connection->getConfig('port'), $connection->getDatabaseName()], JSON_THROW_ON_ERROR)); + } + + public function record(string $started, int $exitCode): void + { + app(Filesystem::class)->replace(storage_path('framework/replenishment-command-status.json'), json_encode([ + 'started_at' => $started, + 'finished_at' => now()->toIso8601String(), + 'scope' => 'SGC replenishment command', + 'result' => $exitCode === 0 ? 'passed' : 'retry_pending', + 'exit_code' => $exitCode, + 'installation' => $this->fingerprint(), + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + } +} diff --git a/app/Services/ReplenishmentProcessLock.php b/app/Services/ReplenishmentProcessLock.php new file mode 100644 index 0000000..46abe90 --- /dev/null +++ b/app/Services/ReplenishmentProcessLock.php @@ -0,0 +1,45 @@ +handle !== null) { + throw new RuntimeException('Verificação periódica já adquirida nesta instância.'); + } + $handle = fopen(storage_path('framework/replenishment-command.lock'), 'c'); + if ($handle === false) { + throw new RuntimeException('Não foi possível abrir o controlo da verificação periódica.'); + } + if (! flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + + return false; + } + $this->handle = $handle; + + return true; + } + + public function release(): void + { + if ($this->handle !== null) { + flock($this->handle, LOCK_UN); + fclose($this->handle); + $this->handle = null; + } + } + + public function __destruct() + { + $this->release(); + } +} diff --git a/app/Services/ReplenishmentQueue.php b/app/Services/ReplenishmentQueue.php index 77b4e91..aacf685 100644 --- a/app/Services/ReplenishmentQueue.php +++ b/app/Services/ReplenishmentQueue.php @@ -21,13 +21,81 @@ class ReplenishmentQueue public function status(User $actor): array { abort_unless(auth()->id() === $actor->id, 403); + $observation = $this->observation(); + + return ['recoveryReady' => $this->ready(), 'scanMinutes' => config('replenishment.scan_minutes'), + 'schedulerStatus' => $observation['schedulerStatus'], 'schedulerRecent' => $observation['schedulerRecent'], + 'work' => $this->ready() ? $this->workStatus($actor->tenant_id) : null]; + } + + /** Workdesk sources, with a bound observation required for technical monitoring. + * @return array + */ + public function health(): array + { + abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403); + + return DB::transaction(function () { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + $ready = $this->ready(); + $observation = $this->observation(); + $work = $ready ? $this->workStatus(null, true) : null; + $reasons = []; + if (! $ready) { + $reasons[] = 'not_installed'; + } + if (! $observation['schedulerRecent']) { + $reasons[] = 'not_recent'; + } + if (($observation['schedulerStatus']['result'] ?? null) !== 'passed') { + $reasons[] = 'last_run_not_successful'; + } + if (! $observation['schedulerBound']) { + $reasons[] = 'unbound_observation'; + } + if ((int) ($work->pending ?? 0) > 0) { + $reasons[] = 'pending_signals'; + } + if ((int) ($work->failed ?? 0) > 0) { + $reasons[] = 'failed_work'; + } + + return ['checked_at' => now()->toIso8601String(), 'ok' => $reasons === [], 'reasons' => $reasons, + 'recovery_ready' => $ready, 'scheduler_recent' => $observation['schedulerRecent'], + 'last_result' => $observation['schedulerStatus']['result'] ?? null, + 'last_finished_at' => $observation['schedulerStatus']['finished_at'] ?? null, + 'work' => $work === null ? null : ['total' => (int) $work->total, 'pending' => (int) ($work->pending ?? 0), 'failed' => (int) ($work->failed ?? 0)]]; + }); + } + + private function workStatus(?int $tenant, bool $global = false): \stdClass + { + return DB::table('replenishment_work')->when(! $global, fn ($query) => $query->where('tenant_id', $tenant)) + ->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() ?? throw new \LogicException('Estado técnico indisponível.'); + } + + /** @return array{schedulerStatus:array|null,schedulerRecent:bool,schedulerBound:bool} */ + private function observation(): array + { $scheduler = null; $recent = false; - $path = storage_path('framework/replenishment-status.json'); - if (app()->environment('payable-testing') && is_file($path)) { + $bound = false; + $path = storage_path('framework/replenishment-command-status.json'); + if (! is_file($path) && app()->environment('payable-testing')) { + $path = storage_path('framework/replenishment-status.json'); + } + if (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)) { + if (array_key_exists('installation', $data)) { + if (! is_string($data['installation']) || ! hash_equals(app(ReplenishmentHeartbeat::class)->fingerprint(), $data['installation'])) { + return ['schedulerStatus' => null, 'schedulerRecent' => false, 'schedulerBound' => false]; + } + $bound = true; + } try { $finished = CarbonImmutable::parse($data['finished_at']); $recent = $finished->greaterThanOrEqualTo(now()->subMinutes(3)) && $finished->lessThanOrEqualTo(now()->addMinute()); @@ -37,8 +105,7 @@ class ReplenishmentQueue } } - 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]; + return ['schedulerStatus' => $scheduler, 'schedulerRecent' => $recent, 'schedulerBound' => $bound]; } public function ready(): bool diff --git a/app/Services/RequisitionAuditTrail.php b/app/Services/RequisitionAuditTrail.php new file mode 100644 index 0000000..ad69864 --- /dev/null +++ b/app/Services/RequisitionAuditTrail.php @@ -0,0 +1,27 @@ + */ + public function query(Requisition $request): Builder + { + return AuditLog::query()->where('tenant_id', $request->tenant_id)->where('requisition_id', $request->id) + ->select(['id', 'action', 'user_id', 'created_at', 'after']); + } + + /** @return array */ + public function row(AuditLog $record): array + { + $mode = $record->after['execution']['mode'] ?? null; + + return ['id' => $record->id, 'action' => $record->action, 'user_id' => $record->user_id, 'created_at' => $record->created_at, + 'execution' => is_string($mode) ? $mode : null]; + } +} diff --git a/app/Services/SupplierEvaluationWorkflow.php b/app/Services/SupplierEvaluationWorkflow.php index 95b8a86..9de783a 100644 --- a/app/Services/SupplierEvaluationWorkflow.php +++ b/app/Services/SupplierEvaluationWorkflow.php @@ -16,6 +16,27 @@ class SupplierEvaluationWorkflow { public const ROLES = [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]; + public function canPrepare(User $actor): bool + { + return $actor->tenant_id !== null && $actor->hasFunctions(self::ROLES) + && ! app(OrganizationAccess::class)->maintenanceOnly($actor) + && (! $actor->departmentScoped() || $actor->department_id !== null); + } + + public function canAct(User $actor, SupplierEvaluation $evaluation, string $action): bool + { + if (! app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation)) { + return false; + } + + return match ($action) { + 'edit' => in_array($evaluation->status, ['draft', 'returned'], true), + 'review' => $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true), + 'revise' => $evaluation->status === 'approved', + default => false, + }; + } + public function reader(User $actor): User { abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403); @@ -67,7 +88,10 @@ class SupplierEvaluationWorkflow { app(ConflictGuard::class)->lockCompany($actor); - return app(CurrentActor::class)->lock($actor, self::ROLES); + $actor = app(CurrentActor::class)->lock($actor, self::ROLES); + abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); + + return $actor; } private function participating(User $actor, int $supplierId): void @@ -92,7 +116,7 @@ class SupplierEvaluationWorkflow { return DB::transaction(function () use ($actor, $supplierId, $operation, $payload) { $actor = $this->actor($actor); - abort_if($actor->departmentScoped() && $actor->department_id === null, 403, 'O perfil de chefia precisa do departamento correspondente.'); + abort_unless($this->canPrepare($actor), 403, 'A preparação exige um perfil operacional e o departamento correspondente.'); $supplier = Supplier::query()->findOrFail($supplierId); $this->participating($actor, $supplier->id); $payload = $this->normalise($actor, $supplier, $payload); @@ -122,6 +146,7 @@ class SupplierEvaluationWorkflow $evaluation = $this->locked($actor, $id, $version); $this->participating($actor, $evaluation->supplier_id); abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409, 'A avaliação não está aberta para edição.'); + abort_unless($this->canAct($actor, $evaluation, 'edit'), 403); $payload = $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $payload); if ($payload === $evaluation->payload) { return $evaluation; @@ -186,6 +211,7 @@ class SupplierEvaluationWorkflow $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $evaluation->payload); if ($action === 'submit') { abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409); + abort_unless($this->canAct($actor, $evaluation, 'edit'), 403); $this->complete($evaluation->payload, false); $evaluation->fill(['status' => 'review', 'submitted_by' => $actor->id, 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])), 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null]); } else { @@ -193,7 +219,7 @@ class SupplierEvaluationWorkflow if ($reason === null || mb_strlen(trim($reason)) < 10) { throw ValidationException::withMessages(['reason' => 'Fundamente a decisão de revisão.']); } - abort_if(in_array($actor->id, $evaluation->contributors, true), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.'); + abort_unless($this->canAct($actor, $evaluation, 'review'), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.'); if ($action === 'approve') { $this->complete($evaluation->payload, true); } diff --git a/app/Services/SupplierImprovementWorkflow.php b/app/Services/SupplierImprovementWorkflow.php index bc877fa..2e60175 100644 --- a/app/Services/SupplierImprovementWorkflow.php +++ b/app/Services/SupplierImprovementWorkflow.php @@ -13,6 +13,12 @@ class SupplierImprovementWorkflow { public function canParticipate(User $actor, SupplierEvaluation $evaluation): bool { + if ($actor->tenant_id === null || $actor->tenant_id !== $evaluation->tenant_id + || ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES) + || app(OrganizationAccess::class)->maintenanceOnly($actor) + || ($actor->departmentScoped() && ($actor->department_id === null || $actor->department_id !== $evaluation->department_id))) { + return false; + } $guard = app(ConflictGuard::class); if ($guard->hasConflict($actor->id, $evaluation->supplier_id, null)) { return false; @@ -26,6 +32,21 @@ class SupplierImprovementWorkflow return true; } + public function canAct(User $actor, SupplierEvaluation $evaluation, SupplierImprovement $measure, string $action): bool + { + if ($evaluation->status !== 'approved' || $measure->tenant_id !== $actor->tenant_id + || $measure->supplier_evaluation_id !== $evaluation->id || ! $this->canParticipate($actor, $evaluation)) { + return false; + } + + return match ($action) { + 'plan' => in_array($measure->status, ['open', 'returned'], true), + 'execute' => $measure->responsible_id === $actor->id && in_array($measure->status, ['open', 'returned'], true), + 'review' => $measure->status === 'review' && ! in_array($actor->id, $measure->contributors, true), + default => false, + }; + } + /** @return array */ public function snapshot(SupplierImprovement $measure): array { @@ -75,7 +96,7 @@ class SupplierImprovementWorkflow abort_if($measure->status === 'closed', 409, 'A medida já está encerrada e preservada.'); $before = $this->snapshot($measure); if ($command === 'plan') { - abort_unless(in_array($measure->status, ['open', 'returned'], true), 409); + abort_unless($this->canAct($actor, $evaluation, $measure, 'plan'), 409); $responsible = $this->assignee($actor, $evaluation, (int) $data['responsible_id']); $measure->fill(['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => $responsible->id, 'due_on' => $data['due_on'], 'contributors' => array_values(array_unique([...$measure->contributors, $actor->id, $responsible->id]))]); @@ -86,7 +107,7 @@ class SupplierImprovementWorkflow throw ValidationException::withMessages(['reason' => 'Justifique a alteração do plano, prazo ou responsável.']); } } elseif (in_array($command, ['progress', 'submit'], true)) { - abort_unless($actor->id === $measure->responsible_id && in_array($measure->status, ['open', 'returned'], true), 403); + abort_unless($this->canAct($actor, $evaluation, $measure, 'execute'), 403); $measure->fill(['result' => $data['result'] ?? '', 'evidence' => $data['evidence'] ?? '']); if ($command === 'submit') { if (trim($measure->result ?? '') === '' || trim($measure->evidence ?? '') === '') { @@ -99,7 +120,7 @@ class SupplierImprovementWorkflow } } else { abort_unless(in_array($command, ['close', 'return'], true) && $measure->status === 'review', 409); - abort_if(in_array($actor->id, $measure->contributors, true), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.'); + abort_unless($this->canAct($actor, $evaluation, $measure, 'review'), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.'); if (mb_strlen(trim($data['reason'] ?? '')) < 10) { throw ValidationException::withMessages(['reason' => 'Fundamente a decisão sobre a execução.']); } diff --git a/app/Services/SupplierParticipation.php b/app/Services/SupplierParticipation.php new file mode 100644 index 0000000..52e0c45 --- /dev/null +++ b/app/Services/SupplierParticipation.php @@ -0,0 +1,26 @@ +tenant_id !== null && $actor->hasFunctions(['procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor); + } + + public function canReviewRole(User $actor): bool + { + return $actor->tenant_id !== null && $actor->hasFunctions(['finance']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor); + } + + public function canReview(User $actor, Supplier $supplier): bool + { + return $actor->tenant_id === $supplier->tenant_id && $this->canReviewRole($actor) + && $actor->id !== $supplier->created_by && $actor->id !== $supplier->last_changed_by; + } +} diff --git a/app/Services/SupplierPayablePage.php b/app/Services/SupplierPayablePage.php new file mode 100644 index 0000000..b01a772 --- /dev/null +++ b/app/Services/SupplierPayablePage.php @@ -0,0 +1,153 @@ + $options + * @return array + */ + public function listing(User $user, ?int $invoiceId, array $options): array + { + abort_unless(DB::connection()->getDriverName() === 'pgsql', 503, 'Leitura paginada requer a composição PostgreSQL conferida.'); + $own = DB::transactionLevel() === 0; + + return DB::transaction(function () use ($user, $invoiceId, $options, $own) { + if ($own) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + $adapter = app(SupplierPayables::class); + $context = $adapter->readContext($user); + $actor = $context['actor']; + $scope = $context['scope']; + $page = (int) ($options['page'] ?? 1); + $size = (int) ($options['per_page'] ?? 25); + $text = trim((string) ($options['q'] ?? '')); + $recognition = $options['recognition'] ?? 'all'; + abort_unless($page >= 1 && $page <= 100000 && in_array($size, [25, 100], true) && mb_strlen($text) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422); + $query = Invoice::query()->where('tenant_id', $actor->tenant_id); + if ($invoiceId !== null) { + (clone $query)->whereKey($invoiceId)->firstOrFail(); + $query->whereKey($invoiceId); + } + if ($text !== '') { + $needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $text).'%'; + $query->where(fn ($q) => $q->where('number', 'ilike', $needle)->orWhereRaw("snapshot->'supplier'->>'name' ILIKE ?", [$needle])); + } + if ($recognition !== 'all') { + $method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists'; + $query->$method(fn ($q) => $q->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id') + ->whereColumn('s.local_source_id', 'invoices.id')->whereColumn('s.tenant_id', 'invoices.tenant_id')->where('s.company_id', 0)->where('s.unit_id', 0) + ->whereColumn('o.tenant_id', 'invoices.tenant_id')->where('o.company_id', 0)->where('o.unit_id', 0)); + } + $pager = $query->orderByDesc('id')->paginate($size, ['*'], 'page', $page); + [$documents, $histories] = $this->sources($actor, $pager->items()); + $read = function () use ($adapter, $user, $context) { + $now = $adapter->readContext($user); + abort_unless($now['scope'] === $context['scope'] && $now['person'] === $context['person'], 409, 'Identidade/contexto alterado; reabra a consulta.'); + }; + $engine = new Payables(DB::connection(), $actor->id, $context['person'], $scope, + static function () { + throw new \LogicException('Snapshots de consulta nunca executam comandos.'); + }, + fn (int $id) => $documents[$id] ?? abort(403), + SupplierPayables::historyEnabled() ? function (int $id) use ($histories) { + $history = $histories[$id] ?? new \DomainException('Histórico ausente nesta página.'); + if ($history instanceof \DomainException) { + throw $history; + } + + return $history; + } : null, $read); + $data = $engine->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $size); + $data['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()]; + $data['listing_query'] = ['page' => $page, 'per_page' => $size, 'q' => $text, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)]; + if ($invoiceId !== null) { + $data['listing_query']['invoice'] = $invoiceId; + } + $data['permissions'] = ['prepare' => $adapter->can($actor, 'prepare'), 'validate' => $adapter->can($actor, 'validate')]; + $data['payments_available'] = app()->environment('payable-testing') && DB::getSchemaBuilder()->hasTable('ps_payments') && $adapter->can($actor, 'payments_view'); + $data['history_import_available'] = SupplierPayables::historyEnabled(); + $data['history_decisions_available'] = SupplierPayables::historyDecisionsEnabled(); + + return $data; + }); + } + + /** @param array $invoices + * @return array{array>,array>|\DomainException>} + */ + private function sources(User $actor, array $invoices): array + { + $rows = collect($invoices); + $ids = $rows->pluck('id')->all(); + $orders = DB::table('purchase_orders')->where('tenant_id', $actor->tenant_id)->whereIn('id', $rows->pluck('purchase_order_id'))->get(['id'])->keyBy('id'); + $suppliers = DB::table('suppliers')->where('tenant_id', $actor->tenant_id)->whereIn('id', $rows->pluck('supplier_id'))->get(['id'])->keyBy('id'); + $lines = InvoiceItem::query()->where('tenant_id', $actor->tenant_id)->whereIn('invoice_id', $ids)->orderBy('id')->get()->groupBy('invoice_id'); + $validations = DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)->where('action', 'invoice.validated')->whereIn('after->invoice_id', array_map('strval', $ids))->orderByDesc('id')->get(['id', 'after']); + $audits = []; + foreach ($validations as $audit) { + $after = json_decode($audit->after, true, 64, JSON_THROW_ON_ERROR); + $audits[(int) $after['invoice_id']] ??= $audit; + } + $historical = SupplierPayables::historyEnabled(); + $payments = collect(); + $counts = []; + $paymentQuery = DB::table('external_payments')->where('tenant_id', $actor->tenant_id)->whereIn('invoice_id', $ids); + if ($historical) { + $payments = $paymentQuery->orderBy('id')->get(['id', 'tenant_id', 'invoice_id', 'amount_minor', 'currency', 'status', 'created_by', 'paid_on', 'reviewed_by', 'reviewed_at']); + $counts = $payments->groupBy('invoice_id')->map(fn ($p) => $p->count())->all(); + } else { + $counts = $paymentQuery->selectRaw('invoice_id, count(*) AS n')->groupBy('invoice_id')->pluck('n', 'invoice_id')->all(); + } + $paymentAudits = []; + if ($historical) { + $auditRows = DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)->whereIn('action', ['payment.registered', 'payment.confirmed', 'payment.rejected'])->whereIn('after->payment_id', $payments->pluck('id')->map(fn ($id) => (string) $id)->all())->orderByDesc('id')->get(['id', 'user_id', 'action', 'after']); + foreach ($auditRows as $audit) { + $a = json_decode($audit->after, true, 64, JSON_THROW_ON_ERROR); + $key = $this->auditKey($audit->action, $audit->user_id, $a['payment_id'] ?? '', $a['invoice_id'] ?? '', $a['amount_minor'] ?? '', $a['status'] ?? ''); + $paymentAudits[$key] ??= $audit; + } + } + $histories = []; + foreach ($payments as $payment) { + $registration = $paymentAudits[$this->auditKey('payment.registered', $payment->created_by, $payment->id, $payment->invoice_id, $payment->amount_minor, 'pending')] ?? null; + $decision = $payment->status === 'pending' ? null : ($paymentAudits[$this->auditKey('payment.'.$payment->status, $payment->reviewed_by, $payment->id, $payment->invoice_id, $payment->amount_minor, $payment->status)] ?? null); + try { + $fact = SupplierPayables::paymentFacts($payment, $registration, $decision); + $current = $histories[$payment->invoice_id] ?? []; + if (! ($current instanceof \DomainException)) { + $current[] = $fact; + $histories[$payment->invoice_id] = $current; + } + } catch (\DomainException $e) { + $histories[$payment->invoice_id] = $e; + } + } + $documents = []; + foreach ($invoices as $invoice) { + $documents[$invoice->id] = SupplierPayables::sourceSnapshot($invoice, $orders->get($invoice->purchase_order_id), $suppliers->get($invoice->supplier_id), $audits[$invoice->id] ?? null, + array_values(($lines->get($invoice->id) ?? collect())->map(fn ($l) => SupplierPayables::lineSnapshot($l))->all()), (int) ($counts[$invoice->id] ?? 0)); + } + + return [$documents, $histories]; + } + + private function auditKey(mixed ...$values): string + { + foreach ($values as $value) { + if (! is_scalar($value) && $value !== null) { + return 'INVALID-AUDIT'; + } + } + + return json_encode(array_map('strval', $values), JSON_THROW_ON_ERROR); + } +} diff --git a/app/Services/SupplierPayables.php b/app/Services/SupplierPayables.php index 4d830ae..79f4bc5 100644 --- a/app/Services/SupplierPayables.php +++ b/app/Services/SupplierPayables.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Enums\PurchasingRole; use App\Models\Invoice; +use App\Models\InvoiceItem; use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -75,16 +76,26 @@ final class SupplierPayables if (! $registration || ($payment->status !== 'pending' && ! $decision)) { throw new \DomainException('Histórico sem auditoria compatível; preservar os originais para conferência.'); } - $result[] = ['id' => (int) $payment->id, 'company' => (int) $payment->tenant_id, 'document' => (int) $payment->invoice_id, - 'amount_minor' => (int) $payment->amount_minor, 'currency' => $payment->currency, 'status' => $payment->status, - 'created_by' => (int) $payment->created_by, 'paid_on' => $payment->paid_on, 'registration_audit_id' => (int) $registration->id, - 'reviewed_by' => $payment->reviewed_by === null ? null : (int) $payment->reviewed_by, 'reviewed_at' => $payment->reviewed_at, - 'decision_audit_id' => $decision ? (int) $decision->id : null]; + $result[] = self::paymentFacts($payment, $registration, $decision); } return $result; } + /** @return array */ + public static function paymentFacts(\stdClass $payment, ?\stdClass $registration, ?\stdClass $decision): array + { + if (! $registration || ($payment->status !== 'pending' && ! $decision)) { + throw new \DomainException('Histórico sem auditoria compatível; preservar os originais para conferência.'); + } + + return ['id' => (int) $payment->id, 'company' => (int) $payment->tenant_id, 'document' => (int) $payment->invoice_id, + 'amount_minor' => (int) $payment->amount_minor, 'currency' => $payment->currency, 'status' => $payment->status, + 'created_by' => (int) $payment->created_by, 'paid_on' => $payment->paid_on, 'registration_audit_id' => (int) $registration->id, + 'reviewed_by' => $payment->reviewed_by === null ? null : (int) $payment->reviewed_by, 'reviewed_at' => $payment->reviewed_at, + 'decision_audit_id' => $decision ? (int) $decision->id : null]; + } + /** @return array */ public function document(User $u, int $id): array { @@ -94,12 +105,49 @@ final class SupplierPayables $supplier = DB::table('suppliers')->where(['id' => $invoice->supplier_id, 'tenant_id' => $u->tenant_id])->first(); $audit = DB::table('audit_logs')->where('tenant_id', $u->tenant_id)->where('action', 'invoice.validated')->where('after->invoice_id', $id)->orderByDesc('id')->first(); - return ['local_id' => $id, 'instance' => config('common-payables.source_instance'), 'company' => (int) $u->tenant_id, 'document' => $id, 'number' => $invoice->number, + return self::sourceSnapshot($invoice, $order, $supplier, $audit, array_values($invoice->items()->orderBy('id')->get()->map(fn ($l) => self::lineSnapshot($l))->all()), + DB::table('external_payments')->where(['tenant_id' => $u->tenant_id, 'invoice_id' => $id])->count()); + } + + /** @return array */ + public static function lineSnapshot(InvoiceItem $line): array + { + return ['id' => $line->id, 'order_line' => $line->purchase_order_item_id, 'quantity_milli' => (int) $line->quantity_milli, + 'subtotal_minor' => (int) $line->subtotal_minor, 'tax_minor' => (int) $line->tax_minor, 'snapshot' => $line->snapshot]; + } + + /** @param list> $lines + * @return array + */ + public static function sourceSnapshot(Invoice $invoice, ?\stdClass $order, ?\stdClass $supplier, ?\stdClass $audit, array $lines, int $paymentCount): array + { + return ['local_id' => $invoice->id, 'instance' => config('common-payables.source_instance'), 'company' => (int) $invoice->tenant_id, 'document' => $invoice->id, 'number' => $invoice->number, 'supplier_id' => $supplier?->id, 'supplier_name' => $invoice->snapshot['supplier']['name'] ?? null, 'order_id' => $order?->id, 'currency' => $invoice->snapshot['currency'] ?? null, 'issued_on' => $invoice->issued_on, 'subtotal_minor' => (int) $invoice->subtotal_minor, 'tax_minor' => (int) $invoice->tax_minor, 'total_minor' => (int) $invoice->total_minor, 'status' => $invoice->status, 'validated_by' => $invoice->validated_by, 'validated_at' => $invoice->validated_at, 'validation_audit_id' => $audit?->id, - 'lines' => $invoice->items()->orderBy('id')->get()->map(fn ($l) => ['id' => $l->id, 'order_line' => $l->purchase_order_item_id, 'quantity_milli' => (int) $l->quantity_milli, 'subtotal_minor' => (int) $l->subtotal_minor, 'tax_minor' => (int) $l->tax_minor, 'snapshot' => $l->snapshot])->all(), - 'payment_count' => DB::table('external_payments')->where(['tenant_id' => $u->tenant_id, 'invoice_id' => $id])->count()]; + 'lines' => $lines, 'payment_count' => $paymentCount]; + } + + /** Fresh read authorization; unlike commands, it never takes writer locks. + * @return array{actor:User,scope:array,person:string} + */ + public function readContext(User $u): array + { + abort_unless(self::enabled() && auth()->id() === $u->id && $u->tenant_id !== null, 403); + $fresh = User::query()->where('tenant_id', $u->tenant_id)->findOrFail($u->id); + $access = app(OrganizationAccess::class); + $contexts = app(WorkContext::class); + abort_if($access->maintenanceOnly($fresh), 403); + if ($contexts->enforced($fresh)) { + abort_unless(app()->bound('request') && request()->hasSession() && $contexts->active(request()) === 'procurement.internal', 403, 'Seleccione Compras internas para consultar obrigações.'); + abort_unless(array_intersect($access->permissions($fresh, 'procurement.internal'), ['finance', 'admin']) !== [], 403); + } + abort_unless($fresh->hasFunctions([PurchasingRole::Finance, PurchasingRole::Admin]) && $this->can($fresh, 'view'), 403); + $scope = $this->scope($fresh); + $person = DB::table('pa_access')->where($scope)->where(['user_id' => $fresh->id, 'action' => 'view'])->value('person'); + abort_unless(is_string($person) && $person !== '', 403); + + return ['actor' => $fresh, 'scope' => $scope, 'person' => $person]; } public function engine(User $u): Payables @@ -118,9 +166,17 @@ final class SupplierPayables }, fn (int $id) => $this->document($u, $id), self::historyEnabled() ? fn (int $id) => $this->paymentHistory($u, $id) : null); } - /** @return array */ - public function listing(User $u, ?int $invoiceId = null): array + /** @param array $options + * @return array + */ + public function listing(User $u, ?int $invoiceId = null, array $options = []): array { + if (config('common-payables.paged_read_enabled') === true) { + abort_unless(in_array('listingPage', get_class_methods(Payables::class), true), 503, 'Pacote financeiro sem leitura paginada; composição explícita pendente.'); + + return app(SupplierPayablePage::class)->listing($u, $invoiceId, $options); + } + abort_if($options !== [], 503, 'Consulta paginada ainda não activada nesta composição.'); $engine = $this->engine($u); $d = $engine->listing(Invoice::query()->where('tenant_id', $u->tenant_id)->pluck('id')->all()); $d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')]; diff --git a/app/Services/SupplierResponseWorkflow.php b/app/Services/SupplierResponseWorkflow.php index 1cd88f4..3e8bda2 100644 --- a/app/Services/SupplierResponseWorkflow.php +++ b/app/Services/SupplierResponseWorkflow.php @@ -10,6 +10,21 @@ use Illuminate\Validation\ValidationException; class SupplierResponseWorkflow { + public function canAct(User $actor, SupplierEvaluation $evaluation, SupplierResponse $record, string $action): bool + { + if ($evaluation->status !== 'approved' || $record->tenant_id !== $actor->tenant_id + || $record->supplier_evaluation_id !== $evaluation->id + || ! app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation)) { + return false; + } + + return match ($action) { + 'edit' => in_array($record->status, ['draft', 'returned'], true), + 'review' => $record->status === 'review' && ! in_array($actor->id, $record->contributors, true), + default => false, + }; + } + /** @return array */ public function snapshot(SupplierResponse $record): array { @@ -55,6 +70,7 @@ class SupplierResponseWorkflow $before = $this->snapshot($record); if ($command === 'save') { abort_unless(in_array($record->status, ['draft', 'returned'], true), 409); + abort_unless($this->canAct($actor, $evaluation, $record, 'edit'), 403); $payload = $this->normalise($data['payload']); if ($payload === $record->payload) { return $record; @@ -62,6 +78,7 @@ class SupplierResponseWorkflow $record->fill(['payload' => $payload, 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]); } elseif ($command === 'submit') { abort_unless(in_array($record->status, ['draft', 'returned'], true), 409); + abort_unless($this->canAct($actor, $evaluation, $record, 'edit'), 403); $errors = []; foreach (['received_on', 'source_reference', 'statement'] as $key) { if (trim($record->payload[$key] ?? '') === '') { @@ -77,7 +94,7 @@ class SupplierResponseWorkflow $record->fill(['status' => 'review', 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]); } else { abort_unless(in_array($command, ['return', 'decide'], true) && $record->status === 'review', 409); - abort_if(in_array($actor->id, $record->contributors, true), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.'); + abort_unless($this->canAct($actor, $evaluation, $record, 'review'), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.'); if (mb_strlen(trim($data['reason'] ?? '')) < 10) { throw ValidationException::withMessages(['reason' => 'Fundamente a análise ou a devolução.']); } diff --git a/app/Services/SupplyCoordination.php b/app/Services/SupplyCoordination.php index 26b7390..5153bc9 100644 --- a/app/Services/SupplyCoordination.php +++ b/app/Services/SupplyCoordination.php @@ -22,6 +22,41 @@ use Illuminate\Validation\ValidationException; class SupplyCoordination { + public function canManage(User $actor, SupplyRecord $record, string $action): bool + { + if ($actor->tenant_id !== $record->tenant_id || ! in_array($record->kind, ['reservation', 'delivery'], true) + || ! app(SupplyPlanning::class)->allows($actor, $record->kind === 'reservation' ? 'supply_reserve' : 'supply_delivery')) { + return false; + } + $latest = app(SupplyPlanning::class)->latest($record->kind)->where('record_key', $record->record_key)->first(); + if ($latest?->id !== $record->id) { + return false; + } + $open = $record->state === 'active' || ($record->kind === 'reservation' && $record->state === 'fulfilled' && $this->remainingReservation($record) > 0); + + return match ($action) { + 'edit', 'cancel' => $open, + 'fulfil' => $record->kind === 'reservation' && in_array($record->state, ['active', 'fulfilled'], true) && $this->remainingReservation($record) > 0, + default => false, + }; + } + + private function independentTechnicalReviewer(User $actor, SupplyRecord $record): bool + { + return $actor->tenant_id === $record->tenant_id && app(SupplyPlanning::class)->allows($actor, 'supply_technical_review') + && ! in_array($actor->id, $record->snapshot['contributors'], true); + } + + public function canReviewAlternative(User $actor, SupplyRecord $record, string $action = 'approve'): bool + { + if ($record->kind !== 'alternative' || ! in_array($action, ['approve', 'return', 'suspend'], true) || ! $this->independentTechnicalReviewer($actor, $record)) { + return false; + } + $latest = app(SupplyPlanning::class)->latest('alternative')->where('record_key', $record->record_key)->first(); + + return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true)); + } + /** @param array $input */ public function save(User $actor, string $kind, array $input): SupplyRecord { @@ -47,6 +82,9 @@ class SupplyCoordination } $old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest($kind)->where('record_key', $v['record_key'])->firstOrFail() : null; $this->require((int) $v['version'] === (int) ($old->version ?? 0), 'version', 'O registo mudou. Actualize antes de continuar.'); + if ($old !== null && in_array($kind, ['reservation', 'delivery'], true)) { + $this->require($this->canManage($actor, $old, $v['action'] === 'save' ? 'edit' : $v['action']), 'action', 'O registo não admite esta intervenção no estado actual.'); + } $data = $old->data ?? []; $snapshot = $old->snapshot ?? []; $state = $old->state ?? 'draft'; @@ -72,7 +110,7 @@ class SupplyCoordination $state = $kind === 'alternative' ? 'draft' : 'active'; } elseif ($kind === 'alternative') { $this->require($old !== null && in_array($v['action'], ['approve', 'return', 'suspend'], true), 'action', 'Acção técnica inválida.'); - $this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A validação técnica exige independência dos preparadores.'); + $this->require($this->independentTechnicalReviewer($actor, $old), 'action', 'A validação técnica exige independência dos preparadores.'); $this->require($v['action'] === 'suspend' ? $state === 'approved' : in_array($state, ['draft', 'returned'], true), 'action', 'Estado incompatível.'); if ($v['action'] === 'approve') { $this->require($data['compatibility'] !== 'pending', 'action', 'Conclua a análise técnica antes de aprovar.'); diff --git a/app/Services/SupplyCoordinationRead.php b/app/Services/SupplyCoordinationRead.php new file mode 100644 index 0000000..4c87e97 --- /dev/null +++ b/app/Services/SupplyCoordinationRead.php @@ -0,0 +1,55 @@ + ['reservation', ['supply_read', 'supply_reserve']], + 'deliveries' => ['delivery', ['supply_read', 'supply_delivery']], + 'alternatives' => ['alternative', ['supply_read', 'supply_technical_prepare', 'supply_technical_review']], + default => abort(404), + }; + abort_unless(collect($permissions)->contains(fn ($permission) => app(SupplyPlanning::class)->allows($actor, $permission)), 403); + + return $kind; + } + + /** @return array */ + public function filters(Request $request, string $kind): array + { + $states = $kind === 'alternative' ? ['draft', 'returned', 'approved', 'suspended'] : ($kind === 'reservation' ? ['active', 'cancelled', 'fulfilled'] : ['active', 'cancelled']); + $filters = $request->validate([ + 'record_search' => 'nullable|string|max:100', + 'record_state' => ['nullable', Rule::in($states)], + ]); + + return ['record_search' => trim($filters['record_search'] ?? ''), 'record_state' => $filters['record_state'] ?? '']; + } + + /** @param array $filters + * @return Builder + */ + public function query(string $kind, array $filters): Builder + { + $search = $filters['record_search']; + + return app(SupplyPlanning::class)->latest($kind) + ->when($search !== '', fn ($q) => $q->where(fn ($match) => $match + ->whereLike('snapshot->item->code', '%'.$search.'%') + ->orWhereLike('snapshot->item->name', '%'.$search.'%') + ->orWhereLike('snapshot->warehouse->name', '%'.$search.'%') + ->orWhereLike('snapshot->order->number', '%'.$search.'%') + ->orWhereLike('data->reference', '%'.$search.'%'))) + ->when($filters['record_state'] !== '', fn ($q) => $q->where('state', $filters['record_state'])); + } +} diff --git a/app/Services/SupplyHistory.php b/app/Services/SupplyHistory.php index 1918200..9481212 100644 --- a/app/Services/SupplyHistory.php +++ b/app/Services/SupplyHistory.php @@ -18,6 +18,23 @@ use Illuminate\Validation\ValidationException; class SupplyHistory { + public function independentReviewer(User $actor, SupplyRecord $record): bool + { + return $actor->tenant_id === $record->tenant_id && app(SupplyPlanning::class)->allows($actor, 'supply_history_review') + && ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true); + } + + /** Eligibility to confer; approval still checks ledger, scope and source changes. */ + public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool + { + if ($record->kind !== 'history' || ! in_array($action, ['approve', 'return', 'suspend'], true) || ! $this->independentReviewer($actor, $record)) { + return false; + } + $latest = app(SupplyPlanning::class)->latest('history')->where('record_key', $record->record_key)->first(); + + return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true)); + } + /** @param array $input */ public function save(User $actor, array $input): SupplyRecord { @@ -58,7 +75,7 @@ class SupplyHistory $this->require($old !== null, 'action', 'Prepare primeiro a análise.'); $data = $old->data; $snapshot = $old->snapshot; - $this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A conferência exige independência de todos os preparadores.'); + $this->require($this->independentReviewer($actor, $old), 'action', 'A conferência exige independência de todos os preparadores.'); $this->require($v['action'] === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível com esta decisão.'); if ($v['action'] === 'approve') { $confirm = Validator::make($v['data'], ['confirm_scope' => 'required|accepted'])->validate(); diff --git a/app/Services/SupplySuggestions.php b/app/Services/SupplySuggestions.php index b14350b..f6f3f5a 100644 --- a/app/Services/SupplySuggestions.php +++ b/app/Services/SupplySuggestions.php @@ -21,6 +21,46 @@ use Illuminate\Validation\ValidationException; class SupplySuggestions { + private function independentReviewer(User $actor, SupplyRecord $record): bool + { + return ! in_array($actor->id, $record->snapshot['contributors'], true); + } + + /** Participation only: approval/conversion still revalidate the frozen sources. */ + public function canAct(User $actor, SupplyRecord $record, string $action): bool + { + $permission = match ($action) { + 'approve', 'return', 'close' => 'supply_suggest_review', + 'convert' => 'supply_suggest_convert', + 'cancel' => 'supply_suggest_prepare', + default => null, + }; + if ($record->kind !== 'suggestion' || $actor->tenant_id !== $record->tenant_id || $permission === null + || ! app(SupplyPlanning::class)->allows($actor, $permission)) { + return false; + } + $latest = app(SupplyPlanning::class)->latest('suggestion')->where('record_key', $record->record_key)->first(); + if ($latest?->id !== $record->id || (in_array($action, ['approve', 'return', 'close'], true) && ! $this->independentReviewer($actor, $record))) { + return false; + } + + return match ($action) { + 'approve', 'cancel' => in_array($record->state, ['draft', 'returned'], true), + 'return' => in_array($record->state, ['draft', 'approved'], true), + 'convert' => $record->state === 'approved' && ($record->snapshot['reviewed_by'] ?? null) !== $actor->id && app(OrganizationAccess::class)->canRequest($actor), + 'close' => $record->state === 'converted' && $this->completionReady($record), + }; + } + + private function completionReady(SupplyRecord $record): bool + { + $request = Requisition::query()->whereKey($record->data['requisition_id'])->firstOrFail(); + $orders = PurchaseOrder::query()->where('requisition_id', $request->id)->get(); + $complete = $orders->isNotEmpty() && $orders->every(fn ($order) => app(ReceiptWorkflow::class)->progress($order)['state'] === 'complete'); + + return $request->status === 'rejected' || $complete; + } + /** @param array $input */ public function save(User $actor, array $input): SupplyRecord { @@ -90,7 +130,7 @@ class SupplySuggestions $snapshot = $old->snapshot; $state = $old->state; if (in_array($v['action'], ['approve', 'return', 'close'], true)) { - $this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A conferência exige independência de todos os preparadores.'); + $this->require($this->independentReviewer($actor, $old), 'action', 'A conferência exige independência de todos os preparadores.'); } if ($v['action'] === 'approve') { $this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão não está disponível para decisão.'); @@ -128,10 +168,7 @@ class SupplySuggestions $state = 'converted'; } elseif ($v['action'] === 'close') { $this->require($state === 'converted', 'action', 'Apenas uma sugestão convertida pode encerrar acompanhamento.'); - $request = Requisition::query()->whereKey($data['requisition_id'])->firstOrFail(); - $orders = PurchaseOrder::query()->where('requisition_id', $request->id)->get(); - $complete = $orders->isNotEmpty() && $orders->every(fn ($order) => app(ReceiptWorkflow::class)->progress($order)['state'] === 'complete'); - $this->require($request->status === 'rejected' || $complete, 'action', 'A necessidade continua em acompanhamento. Aguarde rejeição da requisição ou recepção completa da ordem.'); + $this->require($this->completionReady($old), 'action', 'A necessidade continua em acompanhamento. Aguarde rejeição da requisição ou recepção completa da ordem.'); $state = 'closed'; } if ($v['action'] === 'approve') { diff --git a/app/Services/WarehousePilotReadiness.php b/app/Services/WarehousePilotReadiness.php index 0892373..c680557 100644 --- a/app/Services/WarehousePilotReadiness.php +++ b/app/Services/WarehousePilotReadiness.php @@ -59,9 +59,10 @@ class WarehousePilotReadiness } } $tables = []; - foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue'] as $table) { + foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue', 'requisition_commands'] as $table) { $tables[$table] = Schema::hasTable($table); } + $tables['requisition_process_version'] = Schema::hasColumn('requisitions', 'process_version'); $warehouses = Warehouse::query()->count(); $items = StockItem::query()->get(['id', 'code', 'name', 'unit'])->keyBy('id'); $published = 0; @@ -98,7 +99,7 @@ class WarehousePilotReadiness $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' => 'installation', 'label' => 'Instalação dos percursos', 'ok' => ! in_array(false, $tables, true), 'detail' => in_array(false, $tables, true) ? 'Existem componentes por instalar. A submissão com recuperação segura exige recibos de comandos e controlo de versão da requisição.' : 'Requisições, versões, recibos de comandos, 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'], diff --git a/app/Services/WorkflowTaskInbox.php b/app/Services/WorkflowTaskInbox.php index b758bee..820969d 100644 --- a/app/Services/WorkflowTaskInbox.php +++ b/app/Services/WorkflowTaskInbox.php @@ -12,6 +12,10 @@ use App\Models\QualificationSet; use App\Models\Quotation; use App\Models\RecommendationAttempt; use App\Models\Requisition; +use App\Models\Supplier; +use App\Models\SupplierEvaluation; +use App\Models\SupplierImprovement; +use App\Models\SupplierResponse; use App\Models\SupplyRecord; use App\Models\User; use Carbon\CarbonImmutable; @@ -28,12 +32,151 @@ class WorkflowTaskInbox 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)]; + $rows = $area === 'people' ? $this->people($actor) : [...$this->purchases($actor), ...$this->suppliers($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->suggestions($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->inventoryCorrections($actor), ...$this->lotSetups($actor), ...$this->finance($actor)]; + if ($area === 'procurement') { + $rows = [...$rows, ...$this->supplierImprovements($actor), ...$this->supplierQuality($actor), ...$this->supplyFollowUp($actor), ...$this->lotQuality($actor)]; + } usort($rows, fn ($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '') ?: strnatcmp($a['id'], $b['id'])); return $rows; } + /** @return list> */ + private function lotQuality(User $actor): array + { + $lots = app(InventoryLots::class); + if (! Schema::hasTable('inventory_lot_allocations') + || (! $lots->allows($actor, 'inventory_lot_prepare') && ! $lots->allows($actor, 'inventory_lot_release') && ! $lots->allows($actor, 'inventory_lot_hold'))) { + return []; + } + $rows = []; + foreach ($lots->activeSetups()->lazyById(100) as $setup) { + $context = $lots->context($setup->stock_item_id, $setup->warehouse_id); + foreach ($context['lots'] ?? [] as $entry) { + if ($entry['quantity_milli'] <= 0) { + continue; + } + $metadata = SupplyRecord::query()->whereKey($entry['metadata_id'])->firstOrFail(); + $action = $entry['state'] === 'unidentified' && $lots->canIdentify($actor, $metadata) ? 'identify' + : ($entry['state'] === 'held' && $lots->canDecideLot($actor, $metadata, $context, $entry, 'release') ? 'release' + : ($entry['quality_released'] && ! $entry['eligible'] && $lots->canDecideLot($actor, $metadata, $context, $entry, 'hold') ? 'hold' : null)); + if ($action === null) { + continue; + } + $rows[] = ['id' => 'lot-quality:'.$setup->warehouse_id.':'.$entry['id'].':'.$action.':'.$entry['version'].':'.$entry['state_version'], 'module' => 'warehouse', + 'reference' => $setup->snapshot['item']['code'].' / LOTE-'.$entry['id'].' / '.$setup->snapshot['warehouse']['code'], + 'label' => match ($action) { + 'identify' => 'Completar identificação de lote retido', 'release' => 'Conferir qualidade para libertação independente do lote', default => 'Conferir retenção de lote anteriormente libertado com validade insuficiente' + }, + 'task' => 'lot_'.$action, 'status' => $entry['state'], 'blocked' => false, 'created_at' => $this->date($metadata->created_at), 'due_on' => null, + 'href' => '/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$setup->warehouse_id.'#lot-'.$setup->warehouse_id.'-'.$entry['id']]; + } + } + + return $rows; + } + + /** @return list> */ + private function supplyFollowUp(User $actor): array + { + if (! Schema::hasTable('supply_records')) { + return []; + } + $planning = app(SupplyPlanning::class); + $coordination = app(SupplyCoordination::class); + $rows = []; + foreach (['reservation' => 'supply_reserve', 'delivery' => 'supply_delivery'] as $kind => $permission) { + if (! $planning->allows($actor, $permission)) { + continue; + } + foreach ($planning->latest($kind)->whereIn('state', $kind === 'reservation' ? ['active', 'fulfilled'] : ['active'])->lazyById(100) as $record) { + if (! $coordination->canManage($actor, $record, 'edit')) { + continue; + } + $remaining = $kind === 'reservation' ? $coordination->remainingReservation($record) : $coordination->remainingDelivery($record); + if ($remaining <= 0) { + continue; + } + $rows[] = ['id' => 'coordination:'.$record->id.':followup', 'module' => 'supply', + 'reference' => trim(($record->snapshot['item']['code'] ?? '').' '.($record->data['reference'] ?? $record->snapshot['order']['number'] ?? 'FOR-'.$record->id)), + 'label' => $kind === 'reservation' ? 'Acompanhar necessidade planeada e associar saída efectiva' : 'Acompanhar entrega confirmada ainda por receber', + 'task' => $kind.'_followup', 'status' => $record->state, 'blocked' => false, + 'created_at' => $this->date($record->created_at), 'due_on' => $record->data[$kind === 'reservation' ? 'needed_on' : 'expected_on'], + 'href' => '/compras/fornecimento?view='.($kind === 'reservation' ? 'reservations' : 'deliveries').'&record_id='.$record->id.'#supply-record-'.$record->id]; + } + } + + return $rows; + } + + /** @return list> */ + private function supplierQuality(User $actor): array + { + if (! Schema::hasTable('supplier_evaluations') || ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES)) { + return []; + } + $workflow = app(SupplierEvaluationWorkflow::class); + $responses = app(SupplierResponseWorkflow::class); + $rows = []; + foreach (SupplierEvaluation::query()->visibleTo($actor)->whereIn('status', ['draft', 'returned', 'review'])->lazyById(100) as $evaluation) { + $action = $evaluation->status === 'review' ? 'review' : 'edit'; + if (! $workflow->canAct($actor, $evaluation, $action) + || ($action === 'edit' && ! in_array($actor->id, $evaluation->contributors, true))) { + continue; + } + $rows[] = ['id' => 'supplier-evaluation:'.$evaluation->id.':'.$action.':'.$evaluation->version, 'module' => 'purchases', + 'reference' => 'AVA-'.$evaluation->id.' / R'.$evaluation->revision, + 'label' => $action === 'review' ? 'Conferir avaliação de qualidade do fornecedor' : 'Completar e submeter avaliação de qualidade do fornecedor', + 'task' => 'supplier_evaluation_'.$action, 'status' => $evaluation->status, 'blocked' => false, + 'created_at' => $this->date($evaluation->created_at), 'due_on' => null, 'href' => '/fornecedores/avaliacoes/'.$evaluation->id]; + } + if (! Schema::hasTable('supplier_responses')) { + return $rows; + } + foreach (SupplierResponse::query()->visibleTo($actor)->whereIn('status', ['draft', 'returned', 'review']) + ->whereHas('evaluation', fn ($q) => $q->where('status', 'approved'))->with('evaluation')->lazyById(100) as $record) { + $evaluation = $record->evaluation; + $action = $record->status === 'review' ? 'review' : 'edit'; + if (! $responses->canAct($actor, $evaluation, $record, $action) + || ($action === 'edit' && ! in_array($actor->id, $record->contributors, true))) { + continue; + } + $rows[] = ['id' => 'supplier-response:'.$record->id.':'.$action.':'.$record->version, 'module' => 'purchases', + 'reference' => 'AVA-'.$evaluation->id.' / CTR-'.$record->id, + 'label' => $action === 'review' ? 'Analisar esclarecimento ou contraditório do fornecedor' : 'Completar e submeter esclarecimento do fornecedor', + 'task' => 'supplier_response_'.$action, 'status' => $record->status, 'blocked' => false, + 'created_at' => $this->date($record->created_at), 'due_on' => null, + 'href' => '/fornecedores/avaliacoes/'.$evaluation->id.'#contraditorio-'.$record->id]; + } + + return $rows; + } + + /** @return list> */ + private function supplierImprovements(User $actor): array + { + if (! Schema::hasTable('supplier_improvements') || ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES)) { + return []; + } + $workflow = app(SupplierImprovementWorkflow::class); + $rows = []; + foreach (SupplierImprovement::query()->visibleTo($actor)->whereIn('status', ['open', 'returned', 'review']) + ->whereHas('evaluation', fn ($q) => $q->where('status', 'approved'))->with('evaluation')->lazyById(100) as $measure) { + $action = $measure->status === 'review' ? 'review' : 'execute'; + if (! $workflow->canAct($actor, $measure->evaluation, $measure, $action)) { + continue; + } + $rows[] = ['id' => 'supplier-improvement:'.$measure->id.':'.$action.':'.$measure->version, 'module' => 'purchases', + 'reference' => 'AVA-'.$measure->supplier_evaluation_id.' / MED-'.$measure->id, + 'label' => $action === 'review' ? 'Conferir resultado e evidência da medida de melhoria' : 'Registar execução da medida de melhoria atribuída', + 'task' => 'supplier_improvement_'.$action, 'status' => $measure->status, 'blocked' => false, + 'created_at' => $this->date($measure->created_at), 'due_on' => $measure->due_on, + 'href' => '/fornecedores/avaliacoes/'.$measure->supplier_evaluation_id.'#medida-'.$measure->id]; + } + + return $rows; + } + /** @return list> */ private function purchases(User $actor): array { @@ -60,6 +203,81 @@ class WorkflowTaskInbox return $rows; } + /** @return list> */ + private function suppliers(User $actor): array + { + $access = app(SupplierParticipation::class); + if (! $access->canReviewRole($actor)) { + return []; + } + $rows = []; + foreach (Supplier::query()->where('status', 'pending')->lazyById(100) as $supplier) { + if (! $access->canReview($actor, $supplier)) { + continue; + } + $rows[] = ['id' => 'supplier:'.$supplier->id.':review:'.$supplier->version, 'module' => 'purchases', 'reference' => 'FOR-'.$supplier->id, + 'label' => 'Conferir cadastro do fornecedor', 'task' => 'supplier_review', 'status' => $supplier->status, 'blocked' => false, + 'created_at' => $this->date($supplier->created_at), 'due_on' => null, 'href' => '/fornecedores/'.$supplier->id.'#supplier-review']; + } + + return $rows; + } + + /** @return list> */ + private function inventoryCorrections(User $actor): array + { + if (! Schema::hasTable('supply_records')) { + return []; + } + $rows = []; + foreach (['stock_event' => app(InventoryDispositions::class), 'stock_reversal' => app(InventoryReversals::class)] as $kind => $workflow) { + if (! $workflow->allows($actor, 'review') && ! $workflow->allows($actor, 'execute')) { + continue; + } + $prefix = $kind === 'stock_event' ? 'disposition' : 'reversal'; + $path = $kind === 'stock_event' ? '/stock/ocorrencias' : '/stock/estornos'; + foreach (app(SupplyPlanning::class)->latest($kind)->whereIn('state', ['draft', 'returned', 'approved'])->lazyById(100) as $record) { + $sourceCurrent = $record->state !== 'approved' || $workflow->executionSourceCurrent($record); + $action = $record->state === 'approved' ? ($workflow->canAct($actor, $record, 'execute') ? 'execute' : 'return') : 'approve'; + if ($action === 'return' && $sourceCurrent) { + continue; + } + if (! $workflow->canAct($actor, $record, $action)) { + continue; + } + $rows[] = ['id' => $prefix.':'.$record->id.':'.$action, 'module' => 'warehouse', 'reference' => $record->data['reference'], + 'label' => ! $sourceCurrent ? ($action === 'return' ? 'Conferir origem ou saldo alterados para devolução' : 'Conferir impedimento da correcção física autorizada') + : ($kind === 'stock_reversal' ? ($action === 'execute' ? 'Conferir e executar estorno integral autorizado' : 'Conferir correcção integral de stock') + : ($action === 'execute' ? 'Conferir e confirmar saída física autorizada' : 'Conferir devolução ou abate físico')), + 'task' => $prefix.'_'.$action, 'status' => $record->state, 'blocked' => ! $sourceCurrent, 'created_at' => $this->date($record->created_at), 'due_on' => null, + 'href' => $path.'?record_id='.$record->id.'#'.$prefix.'-'.$record->id]; + } + } + + return $rows; + } + + /** @return list> */ + private function lotSetups(User $actor): array + { + $workflow = app(InventoryLots::class); + if (! Schema::hasTable('inventory_lot_allocations') || ! $workflow->allows($actor, 'inventory_lot_review')) { + return []; + } + $rows = []; + foreach (app(SupplyPlanning::class)->latest('lot_setup')->where('state', 'draft')->lazyById(100) as $record) { + if (! $workflow->canActivate($actor, $record)) { + continue; + } + $rows[] = ['id' => 'lot-setup:'.$record->id.':activate', 'module' => 'warehouse', + 'reference' => $record->snapshot['item']['code'].' '.$record->snapshot['warehouse']['code'], 'label' => 'Conferir configuração e saldo inicial por lotes', + 'task' => 'lot_setup_activate', 'status' => $record->state, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null, + 'href' => '/stock/lotes?record_id='.$record->id.'#lot-setup-'.$record->id]; + } + + return $rows; + } + /** @return list> */ private function warehouse(User $actor): array { @@ -260,29 +478,71 @@ class WorkflowTaskInbox private function supply(User $actor): array { $planning = app(SupplyPlanning::class); - if (! Schema::hasTable('supply_records') || ! $planning->allows($actor, 'supply_review')) { + if (! Schema::hasTable('supply_records') || (! $planning->allows($actor, 'supply_review') && ! $planning->allows($actor, 'supply_history_review') && ! $planning->allows($actor, 'supply_technical_review'))) { return []; } $rows = []; - foreach (['agreement', 'policy', 'auto_rule'] as $kind) { + foreach (['agreement', 'policy', 'auto_rule', 'history', 'alternative'] as $kind) { + $permission = match ($kind) { + 'history' => 'supply_history_review', 'alternative' => 'supply_technical_review', default => 'supply_review' + }; + if (! $planning->allows($actor, $permission)) { + continue; + } 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); + $eligible = match ($kind) { + 'auto_rule' => app(AutomaticPurchases::class)->canReview($actor, $record), + 'history' => app(SupplyHistory::class)->canReview($actor, $record), + 'alternative' => app(SupplyCoordination::class)->canReviewAlternative($actor, $record), + default => $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', + 'agreement' => 'Conferir acordo de fornecimento', 'policy' => 'Conferir política de reposição', 'history' => 'Conferir histórico de consumo', 'alternative' => 'Conferir alternativa técnica', 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]; + : '/compras/fornecimento?view='.match ($kind) { + 'agreement' => 'agreements', 'history' => 'history', 'alternative' => 'alternatives', default => 'calendar' + }.'&record_id='.$record->id.'#supply-record-'.$record->id]; } } return $rows; } + /** @return list> */ + private function suggestions(User $actor): array + { + $planning = app(SupplyPlanning::class); + if (! Schema::hasTable('supply_records') || (! $planning->allows($actor, 'supply_suggest_review') && ! $planning->allows($actor, 'supply_suggest_convert'))) { + return []; + } + $service = app(SupplySuggestions::class); + $rows = []; + foreach ($planning->latest('suggestion')->whereIn('state', ['draft', 'returned', 'approved', 'converted'])->lazyById(100) as $record) { + $action = match ($record->state) { + 'approved' => 'convert', 'converted' => 'close', default => 'approve' + }; + if (! $service->canAct($actor, $record, $action)) { + continue; + } + $rows[] = ['id' => 'suggestion:'.$record->id.':'.$action, 'module' => 'supply', + 'reference' => trim($record->snapshot['item']['code'].' '.$record->snapshot['warehouse']['code']), + 'label' => match ($action) { + 'convert' => 'Preparar compra a partir da sugestão aprovada', 'close' => 'Conferir e encerrar acompanhamento da reposição', default => 'Conferir sugestão de reposição' + }, + 'task' => 'suggestion_'.$action, 'status' => $record->state, 'blocked' => false, + 'created_at' => $this->date($record->created_at), 'due_on' => null, + 'href' => '/compras/fornecimento?view=suggestions&record_id='.$record->id.'#supply-suggestion-'.$record->id]; + } + + return $rows; + } + /** @return list> */ private function quotations(User $actor): array { diff --git a/app/Services/WorkflowTasks.php b/app/Services/WorkflowTasks.php index 75bcdd9..09d33a0 100644 --- a/app/Services/WorkflowTasks.php +++ b/app/Services/WorkflowTasks.php @@ -9,15 +9,28 @@ class WorkflowTasks public function catalogue(): array { $specific = [ + 'inventory_lot_prepare' => ['function.inventory_lot_prepare' => 'Preparar controlo por lotes e completar identificações, preservando os dados anteriores; alterações da identificação exigem nova conferência de qualidade'], + 'inventory_lot_release' => ['function.inventory_lot_release' => 'Conferir e libertar lotes com identificação, validade e saldo elegíveis, com independência de todos os preparadores e intervenientes na recepção'], + 'inventory_lot_hold' => ['function.inventory_lot_hold' => 'Registar retenção de qualidade e conferir lotes anteriormente libertados cuja validade ficou insuficiente, sem alterar o saldo físico'], + 'inventory_lot_review' => ['function.inventory_lot_review' => 'Conferir e activar controlo por lotes e revisões com independência dos preparadores, revalidando saldo, origem e configuração em vigor'], + 'warehouse' => ['function.warehouse' => 'Receber encomendas e executar operações de armazém; confirmar devoluções, abates e estornos previamente autorizados, com independência de quem aprovou'], + 'inventory_reversal_review' => ['function.inventory_reversal_review' => 'Conferir estornos integrais com independência de todos os preparadores, preservando documentos originais e conferência financeira própria'], + 'inventory_disposition_review' => ['function.inventory_disposition_review' => 'Conferir devoluções ao fornecedor e abates físicos com independência de todos os preparadores; devolver para actualização quando necessário'], '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'], + 'supply_reserve' => ['function.supply_reserve' => 'Acompanhar necessidades planeadas; associar saídas efectivas já registadas, rever ou cancelar o saldo pendente; esta reserva de planeamento não bloqueia stock físico'], + 'supply_delivery' => ['function.supply_delivery' => 'Registar confirmações do fornecedor e acompanhar quantidades ainda por receber, reutilizando as recepções efectivas; rever ou cancelar a previsão sem movimentar stock'], + 'supply_review' => ['supply.review' => 'Conferir acordos, políticas e parametrizações de reposição com independência na aprovação; suspender conforme as regras de cada percurso'], + 'supply_history_review' => ['supply.history.review' => 'Conferir cobertura e padrão do histórico calculado a partir das saídas, com independência de todos os preparadores'], + 'supply_technical_review' => ['supply.technical.review' => 'Conferir alternativas técnicas com independência de todos os preparadores, preservando aplicação, sentido, condições e validade'], + 'supply_suggest_review' => ['supply.suggestion.review' => 'Conferir sugestões de reposição com independência e encerrar acompanhamento após rejeição da compra ou recepção completa'], + 'supply_suggest_convert' => ['supply.suggestion.convert' => 'Criar rascunho de compra reutilizando a sugestão aprovada, com competência de requisitante e independência de quem conferiu'], '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 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'], + 'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Conferir cadastro de fornecedor preparado ou alterado por outra pessoa; 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'], @@ -29,6 +42,10 @@ class WorkflowTasks foreach ($specific[$permission] ?? ['function.'.$permission => $label] as $key => $task) { $catalogue[$permission][] = ['key' => $key, 'label' => $task]; } + if (in_array($permission, ['admin', 'procurement', 'finance', 'department_head'], true)) { + $catalogue[$permission][] = ['key' => 'supplier.quality.'.$permission, + 'label' => 'Preparar avaliações de qualidade e esclarecimentos de fornecedores no âmbito autorizado; conferir sem ter participado na preparação; executar medidas quando designado e conferir o encerramento com independência']; + } } return $catalogue; diff --git a/bootstrap/app.php b/bootstrap/app.php index 2f366ee..df3cb49 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\EnterLocalEvaluation; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\PrivateTwoFactorResponse; use App\Http\Middleware\PublicDevelopmentNavigation; use App\Services\EvaluationNavigation; use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests; @@ -30,6 +31,7 @@ return Application::configure(basePath: dirname(__DIR__)) EnterLocalEvaluation::class, HandleAppearance::class, HandleInertiaRequests::class, + PrivateTwoFactorResponse::class, AddLinkHeadersForPreloadedAssets::class, ]); }) diff --git a/config/common-finance-bundle.php b/config/common-finance-bundle.php new file mode 100644 index 0000000..dfcd71f --- /dev/null +++ b/config/common-finance-bundle.php @@ -0,0 +1,3 @@ + env('COMMON_FINANCE_BUNDLE_ROOT'), 'manifest_sha256' => env('COMMON_FINANCE_BUNDLE_SHA256')]; diff --git a/config/common-payables.php b/config/common-payables.php index 79fd4cd..84d4689 100644 --- a/config/common-payables.php +++ b/config/common-payables.php @@ -1,3 +1,3 @@ env('COMMON_PAYABLES_PATH', base_path('packages/payables')), 'source_instance' => env('PAYABLE_SOURCE_INSTANCE', ''), 'enabled' => env('COMMON_PAYABLES_ENABLED', false), 'history_enabled' => env('COMMON_PAYABLES_HISTORY_ENABLED', false), 'history_decisions_enabled' => env('COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED', false)]; +return ['path' => env('COMMON_PAYABLES_PATH', base_path('packages/payables')), 'source_instance' => env('PAYABLE_SOURCE_INSTANCE', ''), 'enabled' => env('COMMON_PAYABLES_ENABLED', false), 'history_enabled' => env('COMMON_PAYABLES_HISTORY_ENABLED', false), 'history_decisions_enabled' => env('COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED', false), 'paged_read_enabled' => false]; diff --git a/config/filesystems.php b/config/filesystems.php index 10c00db..9427192 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -29,6 +29,13 @@ return [ */ 'disks' => [ + 'receipt_documents' => [ + 'driver' => 'local', + 'root' => storage_path('app/receipt-documents'), + 'visibility' => 'private', + 'serve' => false, + 'throw' => true, + ], 'candidate_documents' => [ 'driver' => 'local', diff --git a/config/fortify.php b/config/fortify.php index 90bd32d..5e7b166 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -116,6 +116,7 @@ return [ 'limiters' => [ 'login' => 'login', + 'two-factor' => 'two-factor', ], /* @@ -145,6 +146,10 @@ return [ 'features' => [ Features::registration(), Features::resetPasswords(), + ...(env('SGC_TWO_FACTOR_ENABLED', false) ? [Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ])] : []), ], ]; diff --git a/config/general-ledger.php b/config/general-ledger.php new file mode 100644 index 0000000..3c1acf3 --- /dev/null +++ b/config/general-ledger.php @@ -0,0 +1,4 @@ + false, 'cycle_enabled' => false, 'path' => null]; diff --git a/database/migrations/2026_11_03_000001_add_two_factor_columns_to_users.php b/database/migrations/2026_11_03_000001_add_two_factor_columns_to_users.php new file mode 100644 index 0000000..397967a --- /dev/null +++ b/database/migrations/2026_11_03_000001_add_two_factor_columns_to_users.php @@ -0,0 +1,22 @@ +text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn(['two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at'])); + } +}; diff --git a/database/migrations/2026_11_03_000002_create_receipt_documents.php b/database/migrations/2026_11_03_000002_create_receipt_documents.php new file mode 100644 index 0000000..2767401 --- /dev/null +++ b/database/migrations/2026_11_03_000002_create_receipt_documents.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->foreignId('goods_receipt_id')->constrained(); + $table->foreignId('uploaded_by')->constrained('users'); + $table->string('document_type', 30); + $table->string('original_name', 180); + $table->string('path'); + $table->string('mime_type', 100); + $table->unsignedInteger('size'); + $table->string('sha256', 64); + $table->timestamp('created_at'); + $table->unique(['tenant_id', 'goods_receipt_id', 'document_type', 'sha256'], 'receipt_document_content_unique'); + }); + if (DB::getDriverName() === 'pgsql') { + DB::unprepared("CREATE FUNCTION receipt_documents_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Receipt documents are append only'; END; $$; + CREATE TRIGGER receipt_documents_immutable BEFORE UPDATE OR DELETE ON receipt_documents FOR EACH ROW EXECUTE FUNCTION receipt_documents_immutable();"); + } elseif (DB::getDriverName() === 'sqlite') { + foreach (['UPDATE', 'DELETE'] as $event) { + DB::unprepared('CREATE TRIGGER receipt_documents_'.strtolower($event)." BEFORE $event ON receipt_documents BEGIN SELECT RAISE(ABORT, 'Receipt documents are append only'); END"); + } + } + } + + public function down(): void + { + Schema::dropIfExists('receipt_documents'); + if (DB::getDriverName() === 'pgsql') { + DB::unprepared('DROP FUNCTION IF EXISTS receipt_documents_immutable()'); + } + } +}; diff --git a/docs/auditoria-metadados-requisicao.md b/docs/auditoria-metadados-requisicao.md new file mode 100644 index 0000000..5944cbb --- /dev/null +++ b/docs/auditoria-metadados-requisicao.md @@ -0,0 +1,15 @@ +# Exportação de metadados do histórico da requisição + +03/11/2026. No detalhe da requisição, Histórico > Exportar metadados do histórico (CSV). + +A página e a exportação reutilizam `RequisitionAuditTrail`: identidade do evento, data registada, código da acção, identidade registada do utilizador e modo de execução quando existente. O ficheiro identifica a requisição e o momento da consulta. O modo `scheduled_replenishment` conserva a atribuição técnica ao responsável de origem; não cria uma decisão humana. + +O acesso segue `Requisition::visibleTo`, a empresa e as competências actuais. O exportador volta a conferir o titular, a manutenção e a vigência quando o ficheiro é consumido. Não concede uma função de auditor global ou acesso a outras requisições. O serviço de projecção mantém os mesmos campos já apresentados no ecrã; o payload completo `before`/`after`, dados bancários, anexos e fundamentos privados adicionais não são serializados no CSV. + +Todos os eventos disponíveis são lidos por ordem de identidade, em lotes de 100, numa consulta consistente e somente de leitura em PostgreSQL. A resposta é privada e sem cache. Delimitadores/aspas são tratados como CSV; textos que poderiam ser interpretados como fórmulas são marcados como texto. A consulta e a exportação não acrescentam recibos de leitura nem eventos de auditoria e não alteram os registos históricos. + +Validação em `proofs/requisition-audit.json` e variantes PostgreSQL/HTTP: mais de 100 eventos, paridade com a projecção do ecrã, exclusão de payloads privados, protecção de fórmulas, isolamento do requisitante/departamento/empresa, manutenção/vigência e ausência de alterações de negócio. O piloto usa a requisição sintética existente 2 em leitura, sem repetir decisões. + +Actualização posterior de 03/11: o histórico no ecrã pagina 25 eventos por `audit_page`, com total e navegação parcial que conserva o estado dos formulários. Mantida a ordem dos mais antigos primeiro e o CSV integral. O detalhe volta a carregar a requisição autorizada dentro de uma transacção consistente de leitura em PostgreSQL; o histórico de navegação Inertia é encriptado. Provas `proofs/audit-pagination.json` e variantes PostgreSQL/HTTP. + +Continuam fora desta entrega exportação geral de auditoria, retenção/arquivo, assinatura ou certificação do histórico, garantia face a administradores SQL, capacidade sob carga e revisão visual/da folha de cálculo. O âmbito limitado de metadados não encerra a área de auditoria da matriz. diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md index fc017b0..f10ab7c 100644 --- a/docs/compatibilidade.md +++ b/docs/compatibilidade.md @@ -1,5 +1,7 @@ # Compatibilidade e estado do SGC +**Reconciliação de 03/11/2026:** consultar a [matriz reconciliada do SGC](matriz-sgc-reconciliada.md), que confronta esta referência histórica com código e provas posteriores. As tabelas e contagens abaixo conservam o seu âmbito original; pendências como reposição, relatórios de Pessoas e estornos físicos têm implementação posterior. Não interpretar a porta histórica 8015 como instrução para alterar o piloto sintético 8005 ou a instalação real SGF 0.1.1. A reconciliação não é nova execução das baterias antigas nem aceitação comercial. + **Aceitação posterior, 29/10/2026:** extracção técnica do núcleo comum aceite e checkpoint exclusivamente local autorizado. [Âmbito e pendências](../../sgf-tempo/docs/COMMON_BUDGET_ACCEPTANCE.md): visual Compras, interface do percurso SGF e cobertura MFA permanecem pendentes; nenhuma utilização real habilitada. **Controlo orçamental comum, 29/10/2026:** dotação/reforços/ledger/consumo agora delegam numa implementação única, com equivalência nos dois hosts sintéticos. Ver [entrega, provas e limitações](../../sgf-tempo/docs/COMMON_BUDGET_DELIVERY.md). Não significa Compras completo instalado no SGF nem activação operacional; restantes estados/aceitações preservados. diff --git a/docs/contabilidade-ciclo-sintetico.md b/docs/contabilidade-ciclo-sintetico.md new file mode 100644 index 0000000..0fe06b1 --- /dev/null +++ b/docs/contabilidade-ciclo-sintetico.md @@ -0,0 +1,15 @@ +# SGC autónomo — ciclo contabilístico QA + +Continuação de distribuição: [artefacto financeiro autónomo verificado](../../sgf-tempo/docs/COMMON_FINANCE_BUNDLE_DELIVERY.md), demo nova8052. O host verifica manifesto fixado, carregador e ficheiros antes de carregar o núcleo; flags de operações/direitos mantidas. As instalações/provas abaixo conservam o seu estado e não foram substituídas. + +Autorizado expressamente «Autorizar o ciclo sintético proposto» em02/11/2026. Código funcional no adaptador `app/Services/GeneralLedger.php` e controlador próprio, reutilizando o pacote canónico comum; não há motor contabilístico nativo duplicado nem chamada a SGF remoto. + +**Demo independente: http://127.0.0.1:8051/qa/ledger-demo**. Três funções sintéticas; factura efectivamente conferida pelo workflow nativo, obrigação60MZN, sem pagamentos/lançamentos iniciais na base da equipa. Outra base conserva os ciclos de regressão fechados. Guião: reconhecimento contabilístico60; pagamento sintético20 e afectação confirmada/saldo40; contabilização da afectação; reversão financeira/saldo60 e contabilização; par técnico integral de estorno/substituição60; conferência/revisão/fecho do períodoQA. Sem execução monetária, reabertura ou uso real. Originais e decisões mantidos, sem reset do demo. + +O contexto nativo conserva organização/tenant, Finance/Admin e manutenção/contexto de trabalho existentes. As competências contabilísticas exigem concessões QA explícitas próprias, sem herança de funções financeiras. O contrato comum nativo usa company/unit0; não se declara isolamento de unidades nativas ainda inexistentes. Defaults `config/general-ledger.php`: uso/ciclofalse, pathnull. Só a composição QA configura o pacote. Nenhuma migração/activação no SGC8005 ou destino e nenhum acesso público/real. + +Provas:23 verificações de contrato com rollback,61 HTTP pelo kernel nativo,10 verificações finais de fotografia coerente/leitura sem bloqueios/conservação/paginação e28 de preparação do demo. Um ciclo persistente adicional16 mantém um exemplo fechado, sem cobertura única adicional. O fingerprint original de factura/itens/pagamentos externos/stock/orçamento foi preservado. O relógio da preparação do demo foi fixado explicitamente em29/10/2026; não é política temporal/fiscal de cliente. + +[Relatório conjunto, guião, direitos, concorrência e limitações](../../sgf-tempo/docs/GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md). Manifesto canónico em `C:/laragon/www/sgf-tempo/docs/proofs/common-ledger-complete-cycle-20261102.json`. As demonstraçõesSGF8050 eSGC8051 representam livros independentes; não duplicam responsabilidade operacional. A leitura financeira legada mantém o contrato anterior, sem declaração de desempenho de implantação. + +Preservados o piloto8005, avaliações/decisões já autorizadas, dados/acessos, candidatas e alterações paralelas. Sem publicação, checkpoint ou aceitação presumida. Revisão visual, carga sustentada, fiscalidade/contas reais e implantação permanecem separadas. Nenhuma suite geral ou limpeza de cache foi executada. diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index ddafdfd..cb8ef90 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -2157,3 +2157,442 @@ A autorização não publica artigos nem define mínimos, quantidades, política 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. + +## 02/11/2026 — compatibilidade do núcleo financeiro com leitura paginada SGF + +O SGF passou a usar uma janela documental de25/100 e dependências lidas em conjunto no núcleo canónico packages/payables; cálculo de posição, cadeia de decisões e caminhos de escrita preservados. O adaptador autónomo SGC e o seu pacote instalado não foram alterados/activados por arrasto. A prova nativa usou COMMON_PAYABLES_PATH apenas em cópias e bases QA próprias, não como dependência de servidor SGF remoto. + +Foram conferidos21 casos afectados de histórico/decisões com144 asserções:20 válidos reutilizados da primeira execução e um caso de imutabilidade repetido isoladamente após envolver as violações SQL esperadas em savepoints. A correcção é apenas no teste PayableHistoryImportTest: o PostgreSQL deixava a transacção do teste abortada e a recusa posterior não era avaliada. Uma tentativa intermédia de retoma encontrou função residual do RefreshDatabase; não é prova válida. O launcher agora exige base QA nova para esta suite, preservando provas/bases anteriores. + +Relatório SGF: C:/laragon/www/sgf-tempo/docs/SGF_PAYABLE_PAGED_READ_DELIVERY.md e docs/proofs/sgf-payable-paged-read-20261102.json desse repositório. Comparação financeira em1.000/5.000/10.000, páginas25/100, sem índices novos. Dados/configurações/acessos/demos SGC, pessoas e autoridade financeira real preservados. Sem publicação, migração real, activação de candidatas, pagamentos ou checkpoint. A adaptação paginada do leitor nativo continua trabalho separado; a compatibilidade do motor não significa que essa interface já foi entregue ao SGC. +## 02/11/2026 — tarefas documentais, formais e de reposição integradas no SGC + +As minhas tarefas passa a encaminhar a conclusão das respostas documentais pendentes do próprio analista e a conferência de admissão/recomendação formal. A elegibilidade é reutilizada nos formulários e nos comandos: regime e quórum adoptados, versão/base actual, competência, departamento, empresa, impedimentos e independência. Não se inventa um analista obrigatório nem se transforma uma resposta desfavorável já concluída em análise por terminar. Respostas anteriores do próprio analista são reutilizadas; decisão, evidência e histórico alheios não são copiados. Ligações focam a proposta/tentativa certa mesmo fora da primeira página, preservando a versão consultada e impedindo usar história como decisão actual. Acesso de manutenção não executa; o chefe com administração acumulada continua sujeito ao departamento no percurso documental. + +Fornecimentos e reposição ganhou categoria própria nas tarefas de Compras: conferência de acordos, políticas e regras automáticas pelo responsável competente. A lista/formulário reutiliza SupplyPlanning/AutomaticPurchases; todos os participantes na preparação permanecem excluídos da aprovação, incluindo versões anteriores. Mantida a possibilidade preexistente de suspensão de regra automática por conferente autorizado, sem inventar uma nova segregação nessa acção. Alteração da origem impede aprovação silenciosa mas conserva a devolução permitida pelo motor. Preparações devolvidas mantêm o regime original, sem acrescentar uma etapa fictícia de submissão. + +O foco por record_id/rule_id valida empresa e tipo e apresenta a versão actual com aviso quando a ligação ficou antiga. A fila paginada conserva todas as pendências, inclusive mais antigas do que os100registos do ecrã geral; essa listagem geral de fornecimentos continua limitada a100, não foi declarada integralmente paginada. Acções visíveis por registo seguem a competência efectiva e a independência do mesmo motor. Histórico Inertia cifrado e respostas privadas sem armazenamento; consultar tarefas não cria registos de negócio nem decide. Continuam distintos Pessoas e contratações, Compras autónomo e SGF. + +Provas novas: formal156testes/2860asserções na regressão dirigida e11/383em PostgreSQL descartável; fornecimentos48/838na regressão dirigida e8/262em PostgreSQL descartável. Suites sobrepostas, não somar. Tipos, lint/build, análise PHP, formatação e18hashes de execução passaram. HTTP8005 abriu9componentes formais em3perfis e6páginas de fornecimentos/reposição em2perfis, com separação preparação/conferência e0pendências actuais. Decisões e cenários pendentes foram exercitados nas bases descartáveis. Provas formal-task-integration[-pg/-http].json e supply-task-integration[-pg/-http].json. Sem bateria geral/revisão visual completa/migração/contas/perfis/decisões de negócio persistentes ou alteração SGF. + +### Proposta operacional do piloto, preparada sem aplicação + +Leitura local confirmou ART-TEST «Artigo de stock fictício»,1cx livre/física, ARM-TEST, versão de movimento1 e catálogo ainda não publicado. Proposta concreta em docs/proofs/pilot-replenishment-proposal.json: publicar somente esse artigo como «Outro», categoria de ensaio; política interna/local com segurança0, mínimo1cx, alvo/máximo3cx, consumo previsto0, prazo1dia e conferência1dia, horizonte7dias, embalagem1cx e sem mínimo de encomenda; regra válida até09/11/2026, necessidade em5dias, requisitante sintético9 e centroOPS1. Estimativa artificial10MZN/cx, IVA16% existente:2cx,20MZN líquidos e23,20MZN comIVA. Estes números são inputs propostos do ensaio, não parâmetros comerciais aprovados nem cotação de fornecedor. + +Simulação por scripts/pilot-replenishment-dry-run.php na cópia restaurada e descartável da avaliação actual: publicação por13, preparação por11, conferência independente por12, compra atribuída a9. Tarefas de política/regra/requisitante verificadas; aprovação da regra repetida e duas reavaliações produziram só1rascunho de2cx, sem submissão/etapas humanas automáticas, encomenda ou alteração do stock. Comparados hashes de contas/perfis/atribuições, aprovações, encomendas e movimentos na cópia. Contextos sintéticos existentes reproduzidos apenas na configuração de execução da cópia, sem concessão comercial persistente. A base e o dump privados foram eliminados no finally. Prova pilot-replenishment-dry-run.json vinculada ao hash da proposta. Tentativas iniciais de preparação falharam por localização dos binários/contexto da cópia; não são provas aprovadas e não executaram comandos na origem. A execução final aprovada inclui quantidade, totais, stock e ausência de duplicação. Não existe modo apply neste simulador. + +Aplicar essa publicação/política/regra e criar o rascunho no piloto8005 depende de autorização específica dos valores propostos: a autorização anterior de acessos excluiu expressamente publicações, mínimos e decisões operacionais. Não reabrir a autorização dos perfis já aplicados na versão27. Não executar compra real, aprovar a compra, encomendar, receber, entregar ou tocar no SGF por consequência da proposta. Restante cobertura do workflow continua parcial, nomeadamente execução contratual e outras tarefas especializadas; este incremento não declara concluída toda a proposta Word. + +## 02/11/2026 — reposição do piloto autorizada e aplicada; histórico ligado às tarefas + +O proprietário autorizou expressamente a aplicação da proposta pilot-replenishment-proposal.json apenas no SGC8005 com as contas sintéticas existentes e pediu continuação autónoma do desenvolvimento. Autorização em pilot-replenishment-authorization.json, vinculada ao hash2db14db3dbde06d1bf18ff3e2ae81bb857e5570a2baeafb36b22969e10963e1c. A proposta original conserva o estado histórico de preparação; não voltar a pedir autorização nem reaplicar esta parametrização. + +Aplicação atómica às18:55:41UTC: publicação ART-TEST/ARM-TEST por13; política interna/local preparada por11 e conferida por12, mínimo1/alvo3/máximo3cx; regra válida até09/11/2026, requisitante9/OPS1 e estimativa10MZN/IVA16%. Criado REQ-2026-000002,id2,rascunho2cx/23,20MZN. Sem submissão, aprovação da compra, encomenda ou alteração do stock1cx. Cópia privada antes da mudança em C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-20261102-185536-c4396d/before.dump, SHA419b92072930553ef3f7b87bd08fc6e9bed1c48eb7ba1be97b50dd8977f12cd3; snapshot consistente e restauro conferido112tabelas,106tabelas fora do âmbito preservadas; restauro descartável eliminado. Provas pilot-replenishment-activation.json e pilot-replenishment-activation-http.json: origem reutilizada, tarefa do requisitante, separação preparação/conferência e saldo confirmados. Não foram criadas contas/perfis/grants nem alterado o SGF. + +Histórico de consumo integrado na fila de fornecimentos/reposição. SupplyHistory::independentReviewer/canReview são reutilizados pelos comandos, formulários e tarefas; exige supply_history_review e independência de todos os preparadores. supply_review não concede conferência histórica por arrasto. Ligações record_id validam empresa/tipo e mostram versão actual com aviso quando a ligação ficou antiga. Pendências antigas conservadas na paginação completa das tarefas e no foco, mesmo fora do limite100 da listagem geral; esta listagem geral não foi declarada paginada. Periodicidade/fecho do histórico não são prazos de tarefa inventados. Consulta não escreve/decide; aprovação conserva confirmação humana de âmbito e revalida razão/cadastro/duplicação. Cadastro alterado permite devolver mas impede aprovação silenciosa. Nenhuma competência histórica foi acrescentada às contas do piloto. + +O ensaio da próxima submissão encontrou actualização técnica existente ainda ausente na QA: 2026_11_01_000046_add_requisition_command_versions, distinta de create_workflow_notices com prefixo semelhante. Sem essa actualização, submissões com chave de operação terminavam503; não remover a guarda nem ocultar a falta. A cópia descartável foi actualizada e confirmou submissão/repetição sem duplicação. Instalada apenas essa migração técnica no piloto às19:04:40UTC, com nova cópia privada/restauro112tabelas,110tabelas protegidas e comparação integral das requisições excepto a nova coluna process_version=1. Cópia C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-20261102-190433-e3f169/before.dump, SHAe32297ad0287eb648c7760ce5544c4681e0d35921a4fb5152e663ae49a9045c3. Criada tabela vazia de recibos e dois triggers de imutabilidade; nenhum comando de compra, mudança de estado, conta, grant ou regra foi executado por esta instalação. Prova pilot-command-receipts-installation.json. O diagnóstico passou a verificar tabela e coluna, impedindo declarar cenário pronto quando falta qualquer uma delas. + +Provas desta continuação: regressão dirigida59testes/1030asserções; diagnóstico11/130; PostgreSQL final7/193,histórico e falta de estrutura,base removida. A primeira prova PostgreSQL5/181 é anterior e foi substituída pela execução alargada; suites sobrepostas,não somar. Tipos/lint/build/análise PHP/Pint/18hashes passaram. HTTP de histórico/administração e recusas dos perfis sem competência passaram;0análises históricas actuais. Diagnóstico8005 agora confirma warehouse_scenario_ready=true e replenishment_scenario_ready=true; client_configuration_complete=false preservado, pois não foi outorgado contexto comercial contratado. Provas history-task-integration[-pg/-http].json e logs respectivos. Sem bateria geral ou revisão visual completa. + +Próximo passo operacional preparado, ainda não aplicado: pilot-submission-proposal.json propõe apenas submeter o mesmo rascunho por9, sem mudar2cx/23,20MZN e sem aprovar a compra. Ensaio final em cópia restaurada já com a estrutura instalada passou: estado pending, etapa department_head, único responsável actualmente elegível10 («Chefe de departamento — avaliação local»); tarefa recebida pelo chefe e removida da preparação do requisitante; mesma chave repetida sem duplicar passos. Todas as aprovações humanas permaneceram pendentes; artigos/valores/stock/ordens/acessos preservados. Cópia/base eliminadas. Prova pilot-submission-dry-run.json vinculada ao hash da proposta. A propriedade command_receipt_migration_tested_in_copy=false nessa prova final significa que a origem já tinha a estrutura instalada; o ensaio anterior instalou-a na cópia antes da aplicação técnica. Não interpretar como falta da migração no piloto. Pedir autorização apenas para esta submissão de negócio, excluída da autorização anterior; não para continuar implementação/testes correntes ou para reaplicar perfis/reposição já autorizados. Tarefas de sugestões, execução contratual e outros percursos especializados continuam cobertura por desenvolver. + +## 02/11/2026 — consulta financeira nativa paginada, somente QA + +Entregue NATIVE-PAYABLE-PAGED-READ-SIM-v1: adaptador sobre facturas, linhas, auditorias e pagamentos originais lidos conjuntamente por página; cálculo único no pacote comum, filtros/totais no servidor, factura específica conservada e leitura sem bloqueio de escrita. Tenant, contexto Compras, validade/manutenção e competências revalidados. Comandos usam fontes actuais. Configuração paged_read_enabled=false e pacote instalado preservados; QA usa explicitamente a fonte canónica. Pacote antigo com paginação recusa503. Sem activar piloto8005 ou SGF remoto obrigatório. + +Provas:9testes novos/113asserções;26casos distintos afectados/183asserções (25válidos reutilizados e um alvo corrigido),18verificaçõesHTTP,PHPStan7 sem erros. Em10.000facturas: integral122.903,707ms/80.060consultas/62MiB; página25 75,117ms/76consultas/28MiB; página100 91,089ms/76consultas/28MiB. Uma factura de workflow com obrigação e três pagamentos, restantes cópias por reconhecer; não10.000dívidas. Sem índices novos. Leitor terminou antes da libertação do escritor, sem observar auditoria não confirmada. Original30MZN, stock/orçamento e estados preservados. Ver docs/financeiro-consulta-paginada.md e manifesto SGF docs/proofs/native-payable-paged-read-20261102.json. + +Preservar provas válidas, seis registos protegidos, cache bloqueada, contas/configuração/piloto e trabalho alheio. Sem novas concessões/migrações/pagamentos/publicação/checkpoint. Revisão visual, pacote/activação, carga sustentada e capacidade de implantação continuam separadas. Os registos anteriores sobre paginação apenas SGF descrevem o estado histórico antes desta integração QA; não significam activação no destino. Eventuais índices exigem comparação separada em cópia sintética, sem aplicação por arrasto. + +## 02/11/2026 — submissão autorizada e integração das sugestões de reposição + +O proprietário respondeu «autorizo» à proposta exacta de submeter apenas REQ-2026-000002 pelo requisitante sintético9, sem alterar2cx/23,20MZN nem aprovar a compra. Autorização registada em pilot-submission-authorization.json, vinculada ao hash1be6f272b7df84605a0f1c5d72d75103ab31ab31475a6d791da9bb607503183f. Aplicada às19:20:53UTC: estado pending, etapa department_head, único responsável elegível10. Todas as decisões permanecem pendentes. Reserva orçamental canónica2320cêntimos, compromisso0; stock1cx, dados dos artigos, outras requisições, contas e perfis preservados. Não repetir a submissão nem voltar a solicitar esta autorização. + +Aplicação pelo mesmo RequisitionWorkflow, sob bloqueio da empresa e controlo de versão, com recibo de comando. Antes de confirmar, backup consistente/restauro113tabelas e comparação de106tabelas protegidas; verificadas separadamente as demais requisições, entradas orçamentais anteriores e trabalhos de reposição de outros artigos/locais. Backup privado C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-20261102-192049-fc98ad/before.dump, SHA9ea78c59e6d22160ad889e62b7d252b99085fb378af123a53a0173f66b1727ba; restauro descartável removido. Duas tentativas anteriores foram integralmente canceladas pelo verificador, que inicialmente não admitia os efeitos normais da submissão no orçamento e na fila de reposição. O verificador passou a conferir esses efeitos específicos, sem desactivar os controlos dos restantes dados. Só a aplicação final é prova de submissão. Artefactos pilot-submission-activation.json e pilot-submission-activation-http.json confirmam tarefa no chefe, desaparecimento da tarefa de rascunho, permissões e decisões pendentes. Sem nova migração, conta, grant, aprovação, encomenda, factura, pagamento ou alteração SGF neste incremento. + +As sugestões manuais de reposição passaram a chegar às tarefas de conferência, conversão em rascunho e encerramento do acompanhamento. SupplySuggestions::canAct partilha a autoridade com o formulário; comandos continuam a recarregar actor e origens. A conferência exige independência de todos os preparadores. Converter exige supply_suggest_convert e competência de requisitante, e exclui quem conferiu. Valores, descritores e vínculos da sugestão aprovada são reutilizados; a compra continua rascunho e não recebe aprovação por conversão. Encerramento aparece apenas após rejeição da compra ou recepção completa pelos controlos existentes; não se fecha uma necessidade só por converter. Nenhuma competência especializada foi outorgada por consequência da integração. + +Foco record_id valida empresa e tipo, mostra a última versão com aviso quando a ligação ficou antiga e conserva a história. Tarefas percorrem todas as fontes e usam paginação comum; a visão geral de sugestões mantém limite100. Não são inventados prazos, critérios favoráveis ou centros de custo. Fontes alteradas impedem aprovação/conversão silenciosa; devolução e manutenção/expiração mantêm os controlos anteriores. Consultar não decide nem cria tarefas num segundo motor. + +Provas desta continuação:50testes/1024asserções na regressão dirigida e3/142em PostgreSQL descartável, base removida. Suites sobrepostas, não somar. Tipos, lint/build, PHPStan, Pint e18hashes passaram; HTTP de sugestões e tarefas confirmou administração em consulta e recusa dos perfis sem competências específicas. Sem sugestões manuais actuais no piloto; cenários de conferência/conversão/encerramento exercitados nas bases descartáveis. O encerramento novo foi testado pela rejeição da compra; o critério de recepção completa reutiliza ReceiptWorkflow e não constitui novo ensaio ponta a ponta de recebimento. Provas suggestion-task-integration[-pg/-http].json. Sem bateria geral/revisão visual completa ou implantação SGF; outros trabalhos concorrentes no repositório são preservados. + +Próxima decisão operacional preparada, ainda não aplicada: pilot-approval-proposal.json propõe aprovação sintética por10 da mesma compra2cx/23,20MZN, conforme a única etapa congelada department_head. Prova pilot-approval-dry-run.json em cópia restaurada confirmou aprovação e repetição do comando sem duplicação, tarefa de consulta ao mercado no Procurement11, reserva2320/compromisso0 e preservação de contas/artigos/valores/stock/ordens. O compromisso orçamental pertence ao percurso posterior de emissão, não à aprovação da requisição; um pressuposto incorrecto inicial do ensaio foi corrigido antes da prova final, sem alterar o motor. A base/dump temporários foram eliminados. Aprovar a compra não está incluído na autorização de submissão e depende de autorização própria; selecção de fornecedor, adjudicação e encomenda não integram esta proposta. O estado pendente dos artefactos anteriores de submissão é histórico; não é motivo para pedir novamente o que já foi autorizado e aplicado. Desenvolvimento independente continua permitido; execução contratual e outras tarefas especializadas permanecem por consolidar. + +## 02/11/2026 — aprovação sintética confirmada e tarefas de alternativas técnicas + +O proprietário autorizou expressamente a aprovação de REQ-2026-000002 para consulta ao mercado, sem seleccionar fornecedor nem emitir encomenda. Autorização em pilot-approval-authorization.json, vinculada ao hashcf015e4110db66d4543fd14a5b8769b80bb6369a94d75026d12c04936923a1e7. Decisão registada uma única vez por10 às19:34:34UTC, estado approved, mantendo2cx/23,20MZN, reserva2320cêntimos e compromisso0. Procurement11 recebeu tarefa de completar consulta. Nenhum fornecedor foi seleccionado, nenhuma encomenda/factura/pagamento/movimento de stock foi criado, nem contas/grants/SGF alterados. Estados anteriores de aprovação pendente são históricos; não reaplicar ou pedir novamente autorização. + +O aplicador verificou a tarefa como Procurement antes de terminar o callback pós-commit da decisão do chefe. Essa mudança de actor na ferramenta de verificação produziu403 depois de a aprovação já estar confirmada. A verificação posterior recusou repetir a decisão e identificou o único recibo imutável50990058-e957-4eb2-a5c6-271d3ea63bda e a auditoria. Corrigida a ferramenta para conservar o actor do comando até terminar o commit; não foi alterado o motor de aprovação nem contornada uma guarda funcional. Resultado confirmado por comparação com o restauro original anterior à decisão, sem replay do comando. Backup privado C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-20261102-193430-6b25f8/before.dump, SHAe15b50b70e7b18fed3645223c319320b0b7137ea317efc27694abcafc665f313. Conferidas113tabelas restauradas e107tabelas protegidas, incluindo orçamento, stock, contas, itens e ordens; demais requisições preservadas. Restauro temporário removido. Provas pilot-approval-activation.json e pilot-approval-activation-http.json; approved_at vem da decisão e verified_at distingue a verificação posterior. Não descrever o erro posterior como reversão da aprovação nem executar novamente a decisão. + +Alternativas técnicas integradas na categoria de fornecimentos/reposição das tarefas. SupplyCoordination::canReviewAlternative partilha a elegibilidade com os formulários; o comando revalida o actor e a independência de todos os preparadores. Exige supply_technical_review, sem herdar a competência de supply_review. Análise pendente ou origem alterada pode ser devolvida; a aprovação continua a exigir compatibilidade concluída, validade e especificações actuais pelos controlos anteriores. Aprovar uma análise não muda o sentido da relação, não torna um artigo incompatível elegível, não substitui automaticamente uma peça e não altera stock ou fornecedor. Catálogo dos perfis descreve a tarefa sem outorgar novos direitos. + +Foco record_id na coordenação valida empresa/tipo e mantém acesso à última versão com aviso quando a ligação ficou antiga. Tarefas percorrem todas as pendências; a listagem geral continua limitada100. Acções técnicas por registo são projectadas pelo mesmo serviço; autor não vê autoaprovação. Reservas e entregas conservam as suas competências e escolhas existentes. Consulta não escreve nem decide. Sem criação de alternativas ou concessões técnicas nas contas do piloto. + +Provas novas:55testes/1012asserções na regressão dirigida e3/110em PostgreSQL descartável,base removida. Suites sobrepostas,não somar. Tipos/lint/build/PHPStan/Pint/18hashes passaram. HTTP de alternativas confirmou administração em consulta e recusa dos perfis sem competência específica;0alternativas actuais. Cenários pendentes/independência/origem/manutenção/empresa exercitados apenas nas bases descartáveis. Provas alternative-task-integration[-pg/-http].json. Sem bateria geral ou revisão visual completa; preservar trabalhos concorrentes não relacionados. + +Consulta actual do piloto tem1fornecedor elegível, enquanto a política de comparação já preservada exige3propostas. Proposta concreta pendente em pilot-market-proposal.json: criar2fornecedores inteiramente fictícios para o ensaio (NUITs de teste990000021/990000022,domínio example.test,sem documentos/identidade fiscal real confirmados), preparação por11 e activação independente por12, revisão até09/11/2026; registar3propostas fictícias reutilizando a linha aprovada2cx/IVA16%, preços unitários10/11/12MZN,totais23,20/25,52/27,84MZN,prazos2/3/4dias e validade09/11/2026. Não alterar a estimativa da requisição nem os mínimos de comparação. Sem comunicações externas, novas contas/grants, avaliação/seleção/adjudicação/encomenda ou SGF. + +Ensaio em cópia restaurada da QA actual por pilot-replenishment-dry-run.php --market passou, usando SaveSupplierRequest/SupplierController para preparação e conferência e QuotationController para as propostas. Prova pilot-market-dry-run.json vinculada ao hash:2novos cadastros fictícios independentes,3propostas com totais previstos,0avaliações,nenhuma selecção/ordem e tabelas protegidas intactas; cadastro anterior preservado. Base/dump temporários removidos. Uma tentativa inicial usou nome incorrecto da tabela de avaliações no próprio verificador; corrigida para a relação canónica Quotation::evaluations e repetido o ensaio, sem alterar o motor ou a origem. Proposta não aplicada. A aprovação concedida abrangia a requisição; novos cadastros e condições comerciais artificiais do ensaio carecem de autorização própria. Esta pendência não bloqueia desenvolvimento técnico independente. Execução contratual e outras tarefas especializadas permanecem por consolidar. + +## 02/11/2026 — consulta sintética aplicada e cadastro de fornecedores nas tarefas + +O proprietário autorizou criar e validar dois fornecedores fictícios e registar as três propostas de teste, sem avaliar, seleccionar ou encomendar. Autorização em pilot-market-authorization.json vinculada ao hash99a4c5e4f0673cff8d1093768fedb1a95d389a0c497dacb7f60082f77057749d. Aplicação às19:56:43UTC em8005: fornecedores2/3 preparados por11 e activados independentemente por12, fornecedor existente1 reutilizado; propostas2/3/4 da REQ-2026-000002 com2cx,preços10/11/12MZN,IVA16%,totais23,20/25,52/27,84MZN. Estes são cadastros/identificadores/condições fictícios de ensaio, não identidades fiscais confirmadas, diligência real ou ofertas recebidas. Avaliações0, nenhuma selecção/adjudicação/encomenda/comunicação externa. Contas/perfis,requisição,stock1cx,reserva2320/compromisso0 eSGF preservados. + +Aplicação atómica pelos mesmos controladores e validações do produto, usando o driver partilhado scripts/support/pilot-market-scenario.php também no ensaio descartável. O driver evita divergência entre simulação e aplicação, sem introduzir motor de negócio paralelo. Proposta ensaiada novamente após extracção do driver antes de aplicar. Snapshot consistente e restauro113tabelas conferidos;109tabelas protegidas e propostas/cadastro anteriores preservados. Cópia privada C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-20261102-195637-0f5dde/before.dump, SHAeacc5f0f5525d1537944cf16509e8a14b4080c20944e9d60d03ec9b23fc30615; restauro descartável eliminado. Provas pilot-market-activation.json e pilot-market-activation-http.json confirmam três propostas e três tarefas de avaliação em cada perfil elegível Procurement/Finanças/chefe,sem qualquer avaliação executada. Não reaplicar ou solicitar de novo esta autorização; os estados pendentes no artefacto original são históricos. + +Conferência de cadastro de fornecedor integrada nas tarefas de Compras/Fornecedores, reutilizando SupplierParticipation. Finanças mantém a competência existente, com independência do criador e do último editor; acumular Compras/Finanças não permite auto-conferência. Só fichas pending entram na fila; datas de revisão não foram transformadas em novas atribuições periódicas. Ligações abrem a ficha canónica e acção da versão actual; formulário antigo continua recusado pelo controlo de versão. Fichas activas/suspensas conservam as operações já permitidas por Finanças, sem serem tratadas como novas pendências. Impedimento ou revisão vencida continuam a bloquear activação, mas permitem a suspensão prevista. Nenhuma concessão ou cadastro adicional foi feito por consequência do desenvolvimento. + +Preparação, edição e conferência recarregam o actor depois do bloqueio da empresa, antes da gravação; formulário/política/tarefa reutilizam a mesma autoridade. Função revogada após validar o formulário não é emprestada pela instância antiga de User. Acesso de manutenção consulta sem preparar/editar/decidir; leitura de documentos continua pelo Gate anterior. A ficha ligada às tarefas usa história Inertia cifrada e resposta privada sem armazenamento. DTO da tarefa não transporta NUIT,contactos,beneficiários ou documentos; consulta não escreve nem decide. Fila percorre todos os registos e pagina25, incluindo105fichas pendentes no ensaio. + +Provas novas:94testes/1276asserções na regressão dirigida (manutenção/cotações/avaliação/banco/tarefas/rastreabilidade), mais teste isolado de actor revogado1/2 e teste da ficha privada1/66. PostgreSQL final5/153aprovados em base descartável removida; a primeira execução4/151 precede o caso adicional. Suites sobrepostas,não somar. Tipos/lint/build/PHPStan/Pint/18hashes passaram. A resposta privada foi acrescentada depois da regressão ampla e conferida pelo caso dirigido1/66,HTTP e análise estática do controlador; a regressão ampla não é declarada repetida. HTTP de três perfis confirmou fichas activas,competências e0pendências de cadastro actuais; requisitante sem consulta de fornecedores recebeu403. Artefactos supplier-task-integration[-pg/-http].json e logs respectivos. Sem nova migração,conta,grant,bateria geral,revisão visual completa ou alteração SGF; preservar trabalhos concorrentes alheios. + +Próxima etapa operacional preparada e ainda não autorizada: pilot-evaluation-proposal.json. Propõe seis avaliações inteiramente fictícias das propostas2/3/4, por Finanças12 e chefe10, com5,00/10 iguais em qualidade,histórico e pagamento. Não representam mérito ou desempenho reais. Preço,prazos,pesos e mínimo de dois avaliadores qualificados provêm dos dados e política já preservados, sem os alterar. Ensaio em cópia restaurada da QA actual passou pelos controladores canónicos: seis registos,duplicação da mesma pessoa recusada,comparação pronta e tarefa de conferência humana no Procurement11. Nenhuma selecção/adjudicação/encomenda; cadastros/propostas/contas/stock/orçamento intactos. Base/dump temporários removidos. Prova pilot-evaluation-dry-run.json vinculada ao hash9c6f5004c81030e1ea482e6f5c2c2bf4ed24638886cd9dbe8446e09a2a6f73fa. Um erro inicial de imports no simulador foi corrigido antes da execução final aprovada, sem comando de negócio na origem. A autorização anterior excluiu expressamente avaliar: solicitar autorização própria apenas para estes seis dados sintéticos; não para desenvolvimento técnico corrente. Execução contratual e outras tarefas especializadas continuam por consolidar. + +## 02/11/2026 — continuação independente: ocorrências físicas, estornos e controlo por lotes + +O proprietário reiterou continuação do desenvolvimento e integração, sem autorizar as avaliações fictícias expressamente excluídas na etapa anterior. Prosseguido o trabalho técnico independente. Não repetir a mesma solicitação de avaliações a cada incremento nem transformar essa pendência operacional em bloqueio geral. O piloto mantém REQ-2026-000002 aprovada, três propostas e zero avaliações; nenhuma etapa já autorizada foi reaplicada. + +Devoluções a fornecedores e abates físicos passaram a encaminhar conferência e execução nas tarefas do armazém. Estornos integrais de operação e de linha de recepção foram ligados à mesma fila. Os comandos continuam em InventoryDispositions, InventoryReversals e no escritor canónico StockWorkflow; não há outro movimento, aprovação, factura ou compensação financeira. InventoryReviewParticipation partilha somente elegibilidade: última versão, empresa, competência original, independência de todos os preparadores na decisão e execução por pessoa diferente de quem aprovou. O preparador pode continuar a executar quando o percurso anterior o permite e for diferente do conferente; não se inventa uma obrigação de três intervenientes. Funções/granularidade existentes preservadas, sem novas concessões. + +Consultar ou aprovar não muda stock. Confirmar a execução física reutiliza origem, documento e aprovação; repetição do mesmo comando não duplica saída ou estorno. Correcções preservam documentos e movimentos originais e mantêm conferência financeira separada. Origem ou saldo inválidos continuam a impedir execução. A projecção executionSourceCurrent reutiliza basis dos motores: operador recebe tarefa bloqueada e não vê confirmação física nesse estado; conferentes independentes recebem a acção de devolução já prevista no percurso, sem qualquer devolução automática. Se a origem voltar a ser válida, o impedimento deixa de ser projectado. A projecção verifica a base corrente; o escritor conserva a última palavra e revalida as demais condições antes de movimentar. + +Ligações record_id de ocorrência/estorno validam empresa e tipo, alcançam pendências antigas e mostram última versão com aviso quando o endereço ficou antigo. Actores recebem acções por registo do mesmo resolvedor usado pelos comandos. História Inertia cifrada e resposta privada/no-store. Fila comum completa e paginada25, com105pendências ensaiadas; as listagens gerais de ocorrências/estornos continuam limitadas100. Nenhuma tarefa persistida num segundo motor, prazo fictício ou preenchimento repetido da origem. + +Configurações e revisões do controlo por lotes passaram a chegar à conferência independente inventory_lot_review. Preparação do próprio conferente ou de qualquer outro preparador anterior não pode ser activada por essa pessoa. O comando mantém validação do saldo, configuração activa e assinatura da identificação/disponibilidade. Revisão pendente conserva o controlo anterior em vigor, que permanece visível juntamente com a proposta no foco. Activar não cria movimento físico, não duplica repartição numa revisão e não liberta qualidade automaticamente; estado held continua dependente do percurso de qualidade. Não foram acrescentadas tarefas de identificação/hold/release nem presumidas obrigações de libertar material. Visão geral de configurações continua limitada100por consulta e histórico200; não declarar essa história exaustiva. Pendências usam varrimento completo e foco canónico. + +Foi reproduzida uma pendência obsoleta: duas preparações paralelas e activação de uma delas deixavam a outra na fila, embora o comando já recusasse activá-la. canActivate agora compara a configuração em vigor com previous_setup_id da preparação, tal como a guarda existente; preparação ultrapassada mantém o seu registo histórico, sem tarefa executável. Reprovação esperada do teste inicial em lot-obsolete-reproduction.txt, seguida de correcção e seis casos aprovados em SQLite/PostgreSQL. Não se cancelou ou reescreveu a história nem se alterou a aprovação da configuração vencedora. + +Evidência final:86testes/1244asserções na regressão consolidada de lotes/políticas/devoluções/estornos/requisições/tarefas/rastreabilidade; PostgreSQL10/441para correcções físicas. Após esse conjunto, o filtro final de preparação obsoleta foi verificado nos6casos dirigidos de lotes,6/199em SQLite e6/199em PostgreSQL; não declarar nova execução dos86casos depois dessa pequena correcção. Bases descartáveis removidas. Suites sobrepostas,não somar. Evidências intermédias70/770,75/999,82/1184e anteriores10/413ou5/179 são fases anteriores, não nova bateria geral. O primeiro teste de imutabilidade de operação comparava atributos ainda não hidratados e falhou por chaves nulas/ordem; passou a carregar o registo antes de ambas as comparações e as execuções aprovadas confirmam preservação exacta, sem alterar o motor. Uma falha de teste não é prova de escrita no piloto. + +Tipos/lint/build/PHPStan/Pint e18hashes comuns passaram. HTTP em8005 confirmou as interfaces e recusas dos perfis sem competências: administração em consulta de ocorrências/estornos/lotes; armazém com execução nativa de correcções, sem preparação/conferência adicionais; demais perfis sem essas competências recusados. Não foram concedidas tarefas de lotes por arrasto do papel de armazém. Fontes pendentes/decisões/movimentos foram exercitados apenas nas bases descartáveis;0ocorrências,estornos e configurações de lotes no piloto. Provas inventory-task-integration[-pg/-http].json, lot-task-integration[-pg/-http].json e logs correspondentes. warehouse-task-pilot-status-http.json confirmou três propostas com evaluations=[] em todas, comparação ainda não pronta,requisição approved/23,20MZN e saldo físico1cx. Sem comando de negócio persistente, migração, nova conta/grant, contacto externo, selecção, encomenda, alteração SGF, bateria geral ou revisão visual completa nesta continuação. + +Próxima continuidade: manter as seis avaliações fictícias dependentes da autorização própria já apresentada; continuar independentemente pelos restantes encaminhamentos nativos (por exemplo ocorrências de qualidade, esclarecimentos/medidas de fornecedores e execução contratual), confrontando capacidades com os respectivos motores. Não inferir contas, alçadas, responsáveis, notas favoráveis, documentos ou regras comerciais. Preservar outros trabalhos concorrentes no repositório e não declarar concluída toda a proposta funcional. + +## 02/11/2026 — seis avaliações autorizadas e tarefas de medidas de melhoria + +Autorização expressa do proprietário aplicada uma única vez em8005 às21:09:06UTC: seis avaliações fictícias das propostas2/3/4 de REQ-2026-000002,pelas contas existentes12(Finanças)e10(chefe de departamento),com5/10 em qualidade,histórico e pagamento. Registos1a6;comparação pronta e tarefa de conferência humana para Compras11. Sem seleccionar,adjudicar,encomendar ou comunicar externamente. Provas pilot-evaluation-authorization/activation/activation-http.json;proposta original preservada pelo SHA9c6f5004c81030e1ea482e6f5c2c2bf4ed24638886cd9dbe8446e09a2a6f73fa. Backup41c3d932c2d364da30841429267e2ebf3bf4ae79e416eaf54dda8603cb3def7d restaurado e conferido113tabelas,111protegidas;sem mudar contas/grants,stock,compromisso ou SGF. HTTP confirmou seis notas neutras,ausência das tarefas já cumpridas e requisição ainda approved/23,20MZN. Os estados pendentes/zero avaliações nas secções anteriores são históricos; não repetir estas operações. + +Integração adicional: medidas de melhoria ligadas a avaliações de qualidade aprovadas passam a alimentar a lista de tarefas de Compras. Execução apenas para o responsável já designado;conferência apenas para pessoa independente de todos os contribuidores. Devolução devolve a pendência ao responsável;encerramento retira-a da lista. Reutilizados prazo,referência,versão,estado,evidência e ligação directa #medida-ID da página nativa,sem criar tabela de tarefas,motor de decisão,novos campos obrigatórios ou novas permissões. A lista percorre todos os pendentes em lotes,sem corte nos100registos;continua a usar paginação existente da caixa de tarefas. A fonte aprovada conserva-se imutável. Este acompanhamento de qualidade é distinto das seis avaliações de propostas autorizadas acima. + +SupplierImprovementWorkflow.canAct partilhado por tarefas,página e comandos,com empresa,departamento,funções,conflitos e modo de manutenção revalidados. CanParticipate passou a recusar explicitamente manutenção e âmbito inadequado,também reutilizado nas respostas do fornecedor. Operador dos comandos continua actualizado dentro da transacção. Controller de avaliações passou a exigir utilizador interno autenticado,eliminando ambiguidade com conta de candidato. Sem alterações à sequência de aprovação,delegações ou regras de encerramento. + +Validação final:52testes/819verificações aprovados(SupplierImprovement,SupplierEvaluation,SupplierResponse,SupplierImprovementReport,WorkflowTaskInbox),incluindo5cenários de encaminhamento;estes5também passaram isolados em SQLite57verificações e PostgreSQL descartável57verificações,com remoção da base confirmada. Não somar execuções sobrepostas. PHPStan0erros,Pint e18hashes de execução aprovados. HTTP com Compras,Finanças e chefe confirmou consulta cifrada e caixa de tarefas;as2avaliações de qualidade já existentes foram apenas consultadas,sem novas medidas de piloto. Provas supplier-improvement-task-integration-pg/http.json e logs supplier-improvement-task-*. Sem alteração frontend,novo build,bateria geral ou revisão visual completa nesta etapa. + +Continuidade: desenvolver os restantes encaminhamentos nativos, incluindo contraditório e avaliações de qualidade em preparação/conferência,sem inferir decisões ou criar dados operacionais. Selecção de fornecedor/adjudicação/encomenda permanecem fora da autorização;isso não bloqueia desenvolvimento técnico independente. Preservar alterações concorrentes de contas a pagar e demais módulos. + +## 02/11/2026 — encaminhamento de avaliações de qualidade e contraditório concluído nesta etapa + +A pedido do proprietário para executar tarefas que aproximem a conclusão do produto, integrados dois percursos já existentes na caixa de tarefas de Compras: preparação/submissão e conferência das avaliações qualitativas de fornecedores; preparação/submissão e análise independente dos esclarecimentos/contraditórios. Estas avaliações qualitativas não são as seis avaliações das propostas do piloto. Não há criação automática de avaliação,declaração,nota,decisão ou prazo. + +SupplierEvaluationWorkflow.canAct e SupplierResponseWorkflow.canAct partilham a elegibilidade entre comandos,detalhe e tarefas: empresa,departamento,função actual,conflitos e independência de todos os contribuidores. Rascunhos/devoluções são encaminhados apenas aos contribuidores ainda elegíveis; isso não restringe a edição colaborativa já admitida no motor. Revisão chega às pessoas elegíveis independentes;devolução devolve a tarefa à preparação;aprovação/conclusão retira-a. Cada tarefa aponta à versão corrente da avaliação ou à âncora nativa #contraditorio-ID,sem copiar argumentos,evidência ou conclusão para a lista. Versão integra a identidade da tarefa,sem tabela de pendências paralela. Fila percorre todos os registos em lotes e conserva paginação25,pesquisa e pendências antigas,com105casos de cada percurso exercitados. Consultas não escrevem auditoria nem decisões;aprovação anterior conserva-se imutável. + +Acrescentada relação/âmbito autorizado SupplierResponse.evaluation/visibleTo,reutilizando a visibilidade da avaliação,sem migração. Preparação de avaliação bloqueia manutenção também no servidor;canPrepare alimenta canCreate e oculta o formulário nessas contas. Leitura continua disponível segundo os direitos existentes. Descrição das responsabilidades qualitativas acrescentada ao catálogo dos quatro papéis já admitidos pelo motor,sem concessão adicional. Texto da interface deixou de apresentar esclarecimentos/medidas já implementados como indisponíveis. Sem novos campos obrigatórios,sequência de aprovação ou perfis. + +Validação final:76testes/1276verificações aprovados na regressão SupplierEvaluation,SupplierResponse,SupplierImprovement,SupplierImprovementReport,WorkflowTaskInbox e InternalRequisitionCandidate. Onze cenários novos passaram também em PostgreSQL descartável,180verificações,com remoção da base confirmada. Incluem ciclo completo,devolução,preparação colaborativa,segregação,empresa/departamento,conflitos,manutenção/vigência,revogação de função com instância antiga,paginação e imutabilidade. As execuções intermédias61/977,74/1254 e PG9/158 são anteriores e sobrepostas,não somar. PHPStan0erros,Pint,tipos,lint,build e18hashes comuns aprovados;diff dos ficheiros desta etapa sem erros de espaços. + +HTTP em8005 confirmou a caixa de tarefas cifrada e a preparação disponível para os papéis operacionais existentes. Não existem actualmente tarefas qualitativas elegíveis nesses três perfis do piloto;por isso os percursos pendentes foram validados nas bases descartáveis,sem inventar novos registos no piloto. Confirmadas as seis avaliações de propostas anteriores e comparação pronta,sem seleccionar/adjudicar/encomendar. Provas supplier-quality-task-integration[-pg/-http].json e logs supplier-quality-task-*. Nenhuma migração,conta/grant,alteração SGF,contacto externo,bateria geral ou revisão visual completa executados nesta etapa. + +Pendências de continuidade: encaminhamento de qualidade/identificação/libertação de lotes e execução dos fornecimentos;paginação das consultas gerais ainda limitadas100/históricos200;parametrização das regras opcionais de reservas;instalação comercial do serviço periódico e validações finais de capacidade/recuperação/ecrãs/impressão. Não declarar a aplicação inteira concluída nem confundir funções existentes sem ensaio completo com módulos inexistentes. Preservar alterações concorrentes de contas a pagar e demais módulos. As avaliações operacionais autorizadas já aplicadas não devem ser repetidas. + +## 02/11/2026 — acompanhamento de fornecimentos e paginação da coordenação + +Executado novo incremento de conclusão funcional a pedido do proprietário: reservas de necessidades e entregas confirmadas passam à caixa de tarefas de Fornecimentos/reposição pelas competências existentes supply_reserve/supply_delivery. A reserva tratada aqui é de planeamento,distinta da reserva física warehouse_reservations das requisições internas. A tarefa encaminha acompanhamento,actualização/cancelamento ou associação de uma saída efectiva já existente;não bloqueia stock físico,não recebe automaticamente,não repete movimentos,não selecciona fornecedor nem emite ordem. Prazo reutiliza needed_on/expected_on sem inferir prioridades ou expiração. + +SupplyCoordination.canManage partilhado entre tarefas,página e comandos de coordenação: empresa,competência vigente,manutenção,última versão e estado actual. Tarefa de reserva só enquanto existe necessidade efectiva pendente;tarefa de entrega enquanto falta quantidade perante os recebimentos canónicos. Cancelamento encerra a pendência sem apagar a versão anterior. Estorno de saída torna a necessidade novamente pendente pelo cálculo nativo effectiveApplications,sem reabrir ou alterar o registo fulfilled histórico;uma nova saída autorizada pode satisfazer o saldo. Competência de acompanhamento não concede armazém,recepção ou autorização adicional. DTO de tarefa mínimo,liga ao foco/última versão existente e preserva o histórico,sem tabela paralela ou preenchimento repetido. + +As consultas de coordenação alternativas/reservas/entregas deixaram o corte100:paginação25 com total,anterior/seguinte e parâmetro records_page validado. Foco record_id mantém acesso directo à versão actual,independentemente da página;nenhum formulário novo no foco. Fonte de ordem/requisição seleccionada no foco é consultada explicitamente para preservar a edição de origens antigas,sem depender dos últimos100documentos gerais. As listas gerais de escolha de origem/saídas ainda têm limites100;isso não equivale a todos os selectores/históricos paginados. Sem alterar os demais ecrãs de SupplyPlanning nem prometer paginação global concluída. Responsabilidades supply_reserve/supply_delivery descritas no catálogo dos perfis com as permissões existentes;texto da interface clarifica planeamento vs reserva física. + +Validação final52testes/898verificações aprovados em SupplyCoordination,InventoryReversals,SupplyPlanning e WorkflowTaskInbox. Seis cenários de coordenação passaram também em PostgreSQL descartável107verificações,com base removida. Exercitados satisfação parcial/integral por saídas,recepção parcial/integral,cancelamento idempotente,estorno e nova satisfação,empresa,tipo de foco,manutenção/vigência,105pendências,paginação da caixa e consulta,foco antigo,consulta sem auditoria/movimento e preservação histórica. A primeira preparação dos105registos de teste omitia created_at ao replicar um modelo sem timestamps automáticos;corrigida apenas a fixture e reexecutadas as provas finais. A falha não corresponde a operação no piloto. PHPStan0erros,Pint,tipos,lint,build,diff e18hashes comuns aprovados. Não somar execuções intermédias ou os testes PG sobrepostos à regressão. + +HTTP8005:administraçao consulta as três páginas com paginação,sem preparação operacional;armazém/Compras/Finanças sem competências específicas receberam403,conforme configuração actual preservada. Nenhuma tarefa nova de coordenação pendente nesses perfis:cenários operacionais exercitados só nas bases descartáveis. Não criar grants,previsões,reservas,recepções ou movimentos no piloto para forçar demonstração. Provas coordination-task-integration[-pg/-http].json e logs coordination-task-*. Sem migração,conta nova,contacto externo,alteração SGF,decisão comercial,bateria geral ou revisão visual completa. + +Próximas lacunas:encaminhamento de identificação/retenção/libertação de lotes,paginação das restantes consultas gerais e selectores,instalação comercial resiliente do serviço periódico e validação final de capacidade/recuperação/ecrãs/impressão. Acompanhamento de acordos,execução contratual,importações e cobertura integral dos documentos base devem continuar confrontados com o código,sem declarar todos concluídos por esta integração. Preservar mudanças concorrentes de contas a pagar e demais módulos. As autorizações e avaliações de propostas anteriores continuam efectivas e não devem ser repetidas. + + +## Ciclo contabilistico comum sintetico - entrega autorizada + +Adaptador SGC e interface comum implementados: reconhecimento60, afectacao20/saldo40, contabilizacao, reversao/saldo60, par correctivo imutavel e fecho QA. Demo proprio http://127.0.0.1:8051/qa/ledger-demo com tres funcoes e base nova, sem efeitos de negocio iniciais. Ver docs/contabilidade-ciclo-sintetico.md e relatorio conjunto SGF. Provas nativas:23 controlos de contrato revertidos,61HTTP,10finais e28de preparacao; fontes/stock/orcamento externos preservados. Concessoes apenas QA, destino desactivado, sem alterar piloto8005 ou candidatas. Revisao visual, carga de implantacao, uso/fiscalidade reais e aceitacao separados; sem publicacao/checkpoint presumidos. + + +## Composicao autonoma do nucleo financeiro - QA + +Artefacto com25ficheiros de runtime, manifesto SHA256 fixado e verificacao antes do arranque, sem fallback quando alterado. FinanceBundle.configure e opt-in apenas em base/role nao elevada do cluster55439; sem root conserva composicao anterior e nao activa direitos/contabilidade. Demo nova8052, sem substituir8051/8005 ou pacote instalado. Provas:9contrato/escrita/replay,6integridade,4recusas no arranque e28preparacao do demo; PHPStan0erros nos2ficheiros novos. Ver SGF docs/COMMON_FINANCE_BUNDLE_DELIVERY.md. Sem dados/credenciais no artefacto, migracao no destino, publicacao ou checkpoint. + +## 02/11/2026 — encaminhamento da identificação e qualidade dos lotes + +Implementado acompanhamento na caixa de tarefas de Armazém sobre as configurações activas e o contexto canónico InventoryLots: identificação pendente para inventory_lot_prepare; conferência de lote retido elegível para inventory_lot_release independente dos contribuidores; conferência de retenção para inventory_lot_hold quando uma libertação anterior existe mas a validade actual é insuficiente. Não são atribuídas retenções a todos os lotes libertados saudáveis nem inventados prazos,prioridades,novos perfis ou decisões. O vencimento é lido pela data actual sem exigir movimento físico;criar/consultar tarefa não grava decisão nem aviso persistente. + +canIdentify/canDecideLot partilham elegibilidade entre comandos nativos,interface e tarefas: empresa,competência vigente,manutenção,versão de identificação,saldo repartido conferido,quantidade positiva e independência/validade para libertar. Mantidas verificações finais de versão e state_version no comando sob bloqueio da empresa. Identificação de lote sem saldo continua possível no motor quando já era permitida;apenas não gera tarefa. A identificação corrigida invalida a libertação anterior conforme o motor existente,sem alterar a decisão histórica. A dispensa de quarentena na recepção continua dependente da configuração aprovada existente;as novas tarefas não impõem nova aprovação nesse percurso. + +Novas ligações de foco lot_id+warehouse_id validam identidade inicial do lote,empresa,armazém,configuração activa e presença nesse local;abrem a identificação mais recente e a âncora estável lot-ARMAZEM-ID,mesmo com link antigo ou mais100pendências. Não misturar esse foco com record_id de configuração. Tipo numérico do identificador normalizado na resposta. Ligações antigas de configuração permanecem válidas;regra de exclusão corrigida para Rule::prohibitedIf suportada na versão instalada. Interface usa acções por lote da mesma autoridade;permite a retenção explícita de uma libertação já expirada,sem restaurar a disponibilidade pelo simples acesso. Descrições das tarefas de identificação,libertação e retenção acrescentadas aos perfis existentes,sem grants. + +Validação funcional abrange identificação→conferência→libertação,contribuidores de recepção,expiração sem movimento e retenção explícita,correcção posterior/versão antiga,empresa e par de foco/manutenção/vigência,saldo não reconciliado,105lotes em100partições iniciais+5de recepção nativa,paginação da caixa,política aprovada de recepção e lotes sem saldo. O saldo não reconciliado é injectado na leitura no teste:os triggers recusaram correctamente a tentativa inicial de preparar uma repartição impossível,não foram desactivados ou contornados. Expectativas antigas passam a reconhecer tarefas distintas de conferência da configuração e qualidade do saldo activo,sem alterar o estado ou a aprovação do processo. A evidência final desta execução é registada a seguir após a conclusão da regressão. + +No piloto8005 apenas consultas:administração continua só de leitura e os outros perfis sem competências específicas continuam403;nenhum lote/configuração/grant/decisão/movimento criado para demonstração. Prova HTTP lot-quality-task-integration-http.json. As validações operacionais permanecem nas bases descartáveis. Listagens gerais de configurações/histórico de lotes ainda conservam os limites anteriores100/200;foco e caixa paginada não equivalem a paginação integral desses históricos. Sem alteração SGF,selecção,adjudicação,encomenda ou contactos externos. + +### Evidência final — qualidade de lotes, 02/11/2026 + +Regressão final aprovada:99testes/1578verificações em InventoryLots,InventoryLotPolicy,InventoryReversals,InventoryDispositions,WarehouseFulfilment e WorkflowTaskInbox. Oito cenários específicos passaram em PostgreSQL descartável106verificações,com remoção da base confirmada. Execuções intermédias falhadas e o ensaio8/105 são anteriores/não somar. Foram corrigidos o tipo numérico do foco,a regra de validação das ligações e as expectativas dos testes de configuração,que agora reconhecem também a tarefa separada de libertação do saldo existente. Nenhuma falha de teste foi apresentada como decisão operacional aplicada. O cenário de leitura não reconciliada injecta a condição no teste e conserva os triggers físicos efectivos. + +Tipos,lint,build,Pint,PHPStan0erros,diff e18hashes comuns aprovados. O primeiro controlo de tipos detectou a âncora a usar warehouse_id fora de data;corrigido para a origem já usada na página e repetidos tipos/build. Provas lot-quality-task-integration[-pg/-http].json e logs lot-quality-task-*. Consulta HTTP com quatro perfis confirmou acesso de leitura administrativo,recusas operacionais sem competência e ausência de novas tarefas/lotes no piloto. As ligações e decisões com pendências foram testadas nas bases descartáveis. Não equivale a revisão visual completa,teste de carga,bateria geral de todos os módulos,aceitação real ou instalação SGF. + +Próxima continuidade técnica:paginar as consultas gerais de configurações/histórico de lotes e as restantes consultas/seletores ainda limitados;consolidar o arranque e recuperação do serviço periódico para instalação comercial;confrontar a cobertura de acordos/importações/execução contratual com os documentos base;validar capacidade,recuperação,relatórios,impressões e percursos por perfil. As tarefas de identificação/libertação/retenção aqui descritas deixaram de ser uma lacuna de encaminhamento. Não criar automaticamente concessões ou decisões no piloto para as tornar visíveis. Preservar aprovações vigentes,os percursos distintos de Compras/Pessoas e alterações concorrentes. O desenvolvimento e a integração interna mantêm autorização;novas decisões operacionais continuam a exigir o seu âmbito próprio. + +## 03/11/2026 — consultas de lotes paginadas, sem separar controlo activo da revisão + +Retirados os limites fixos100configurações/200registos históricos da página /stock/lotes. As configurações paginam25identidades de controlo por página;para cada identidade são carregadas a última versão e a configuração activa. Assim a revisão pendente e o controlo em vigor não se separam na fronteira da página. A contagem corresponde a controlos, podendo existir até duas versões visíveis por controlo. O foco de configuração/lote mantém acesso directo sem paginação de controlos. A lista de lotes de cada contexto continua integral nesse detalhe,não é um paginador individual por lote. + +Histórico paginado50registos,com total e navegação anterior/seguinte. controls_page/history_page independentes,validados e conservados nas ligações, juntamente com o foco. A segunda página do histórico abre o respectivo detalhe automaticamente. O histórico mantém o âmbito empresarial autorizado anterior;esta etapa não acrescenta pesquisa,filtros ou exportação. A leitura da página passou a transacção coerente e somente de leitura em PostgreSQL,sem bloqueio de escritor nem auditoria/movimento gerados em GET. + +Validação final51testes/821verificações aprovados em InventoryLots,InventoryLotPolicy e WorkflowTaskInbox. Os três cenários novos passaram também em PostgreSQL descartável98verificações,com base removida. Cobrem210controlos/registos de histórico,alcance das páginas antigas,preservação de parâmetros,foco directo,par activo+revisão na fronteira,empresa,páginas inválidas e leitura sem alteração de dados. A expectativa antiga100registos foi ajustada para25por página com105no total e acesso à quinta página. Tipos,lint,build,Pint,PHPStan0erros,diff e18hashes comuns aprovados. Provas lot-pagination[-pg/-http].json e logs lot-pagination-*. HTTP confirmou paginação vazia administrativa e recusas403 dos perfis sem competências;nenhum novo dado/grant no piloto. + +A regressão99/1578 e PG8/106 da etapa de qualidade imediatamente anterior continuam evidência própria dessa etapa,antes desta paginação;os51/821são a regressão final posterior da leitura alterada. Não somar provas sobrepostas nem declarar nova bateria integral dos módulos. Sem migração,aprovação de negócio,selecção,ordem,alteração SGF,contacto externo ou revisão visual completa. + +Continuar pelos restantes selectores/consultas gerais ainda limitados,preparação comercial do serviço periódico e recuperação,confronto da execução contratual/acordos/importações com os documentos base e validação final de capacidade,relatórios,impressões e percursos. As consultas gerais de configurações/histórico de lotes e o encaminhamento de qualidade deixaram de estar pendentes. Preservar aprovações e separação dos módulos;novas competências ou decisões do piloto não são concedidas por inferência. + +## 03/11/2026 — paginação de fornecimentos e preservação das origens escolhidas + +Retirado o corte fixo100 das consultas de acordos,políticas/calendário,análises de consumo,sugestões e processos de importação. RecordPage partilhado apresenta25identidades/últimas versões,contagem,navegação anterior/seguinte e records_page validado. Record_id conserva foco directo na última versão,sem depender da página,nem reintroduzir dados das origens. Componente de paginação partilhado nas três páginas de fornecimentos,sem novo motor de tarefas ou permissões. Consultas de coordenação,que já tinham paginação,mantêm o percurso próprio existente. A página de fornecimentos passou a leitura coerente,somente de leitura em PostgreSQL;projecções e contagens usam o mesmo estado sem bloquear operações de stock. + +As opções aprovadas de análise histórica/política passam a incluir as origens já usadas pelos registos visíveis,mesmo quando ficaram fora das100escolhas recentes. Só a última versão ainda aprovada é oferecida;uma suspensão remove a escolha e conserva a referência histórica no documento anterior. Nenhum GET altera snapshots,notas,pesos,aprovações,compras ou razão de stock. As100escolhas recentes continuam uma janela de apresentação,não uma promessa de selectores todos paginados. Acordos e catálogos ainda mantêm as capacidades/listas existentes. Não declarar toda a parametrização/escolha de origens concluída por esta etapa. + +Preparação de importações ganhou pesquisa opcional da ordem pelo número ou parte do número,aplicada antes da janela100,com parâmetros de pesquisa vinculados e comparação sem distinguir maiúsculas. Mantém no conjunto a ordem actualmente seleccionada/as ordens dos processos visíveis. Só quem já pode preparar recebe as opções;selected_order_id é validado por empresa e os leitores não recebem o selector. A pesquisa actualiza apenas orders/orderSearch via Inertia,preserva dados do formulário,página de registos e posição;Enter na pesquisa não submete o processo. Continua a usar a ordem canónica e o comando nativo de importação,que impede duplicação e exige registo antes da primeira recepção. + +Os testes incluem105registos por vista,quinta página/foco antigo,isolamento,páginas inválidas,vigência/manutenção,versões de importação,origens aprovadas fora da janela recente,retirada da opção quando suspensa,referências imutáveis,pesquisa/sem resultados/ordem seleccionada,entradas com apóstrofo,comprimento e empresa/leitura sem preparação. A primeira fixture de importação já tinha recepção completa e foi correctamente recusada pelo motor;substituída por uma fixture nova sem recepção,não se enfraqueceu essa regra. As expectativas antigas100registos passaram a25por página e contagens totais. Os resultados finais desta execução são registados após a conclusão das verificações. + +Piloto8005 apenas consultado nos perfis existentes,sem grants,cadastros,importação,aprovação,selecção,ordem ou movimento novo. RootAGENTS recebeu noutro trabalho autorização do ciclo contabilístico sintético em8051;esse âmbito paralelo é preservado e não alterado por esta etapa,assim como o SGF real. Prova HTTP supply-pagination-http.json cobre as cinco vistas e acessos actuais;os cenários com volume e decisões de teste só nas bases descartáveis. Sem revisão visual completa,teste de carga ou nova execução geral de todos os módulos. + +### Evidência final — consultas de fornecimentos e pesquisa de encomendas, 03/11/2026 + +Regressão final73testes/1378verificações aprovada em SupplyPagination,SupplyPlanning,SupplyHistory,SupplySuggestions,SupplyCoordination e SupplyTaskIntegration. Catorze cenários dirigidos passaram também em PostgreSQL descartável356verificações,com remoção da base confirmada. Incluem a suspensão posterior das origens:deixam de ser oferecidas para reutilização,mas os documentos preservam o identificador e os dados anteriores. As fases70/69aprovados,72/1329 e PG13/280ou307 são anteriores/sobrepostas,não somar nem declarar bateria geral. O único erro da preparação inicial de importação resultou de fixture já recebida;foi corrigida a fixture sem alterar a exigência nativa. + +Pint,PHPStan0erros,lint,tipos,build,diff e18hashes comuns aprovados. O artefacto compilado confirmou que a pesquisa conserva records_page. HTTP com administração,Compras e Finanças confirmou as cinco vistas/paginação conforme os acessos actuais e que a pesquisa não concede opções de preparação ao leitor. Provas supply-pagination[-pg/-http].json e logs supply-pagination-*. As escolhas de processos antigos e os dados em volume foram ensaiados nas bases descartáveis. Nenhuma decisão/grant/cadastro/stock/SGF alterado por estes comandos de desenvolvimento;sem nova revisão visual completa,teste de carga ou aceitação real. + +Próxima continuidade:demais selectores ainda limitados (por exemplo saídas efectivas para afectação de necessidades e novas escolhas de análises/políticas sem referência anterior),restantes consultas de ocorrências/estornos,preparação comercial do serviço periódico com recuperação e verificações de capacidade/relatórios/impressões. Confrontar acordos/importações/execução contratual com os documentos base antes de declarar a aplicação completa. A paginação principal de acordos,calendário,histórico,sugestões e importações e a pesquisa de ordens para importação deixaram de estar pendentes. Manter as decisões já autorizadas;não criar novas competências ou decisões no piloto por inferência. Preservar trabalhos paralelos,em particular o ciclo contabilístico autónomo e contas a pagar. + +## 03/11/2026 — consultas de ocorrências e estornos consolidadas + +Retirado o corte100processos em /stock/ocorrencias e /stock/estornos. InventoryCorrectionPage é uma projecção de leitura partilhada,sem novo motor ou permissões;os controladores mantêm a autoridade nativa e os comandos anteriores. Paginação25,contagem e filtros opcionais por referência/estado sobre as últimas versões. Pesquisa sem distinção de maiúsculas,parâmetros validados e conservados nas ligações. O foco directo ignora os filtros para apresentar a versão actual e o histórico integral desse processo,com as acções nativas de conferência/devolução/execução. Páginas em transacção coerente somente de leitura PostgreSQL,privadas e com história cifrada. Mantido o redireccionamento anterior para estornos quando só esse módulo está autorizado. + +Componentes de paginação e filtros reutilizados;filtros recolhidos quando não usados,sem novos campos de negócio obrigatórios. As escolhas de recepções e operações passam a conservar as origens já seleccionadas dos processos visíveis,mesmo fora das200recentes. Origens efectivamente estornadas são excluídas antes de aplicar a janela de escolhas e não são reintroduzidas por estarem num documento anterior. Referências,snapshots,aceitação original e documentos continuam imutáveis;as quantidades e a execução dependem das verificações nativas actuais. A janela200de novas escolhas permanece;isto não constitui pesquisa/paginação universal de todas as origens. + +Validação final55testes/1085verificações aprovados:InventoryCorrectionPagination,InventoryDispositions,InventoryReversals e WorkflowTaskInbox. Dez cenários específicos também aprovados em PostgreSQL descartável251verificações,com remoção da base confirmada. Cobertura:210processos por percurso,páginas antigas,filtros apenas sobre versão actual,foco antigo/histórico/acções independentes,empresa/tipo/páginas inválidas/manutenção/vigência,recepção e operação escolhidas fora das200recentes,estorno nativo e exclusão posterior das escolhas,consulta sem alterar stock,orçamento,auditoria ou decisões. As origens de volume foram criadas pelos comandos nativos em bases de teste;as correcções de teste usaram preparação,aprovação e execução independentes. Não somar provas sobrepostas nem declarar bateria geral. + +Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. Provas inventory-pagination[-pg/-http].json e logs inventory-pagination-*. HTTP8005 confirmou administração em leitura,armazém com execução anterior sem preparação/conferência nova,Compras/Finanças sem essas competências403,paginação/filtros vazios e validação dos parâmetros. Nenhuma concessão,ocorrência,estorno ou movimento novo no piloto,nem alteração SGF/8051,contacto externo ou decisão comercial. Sem revisão visual completa ou teste de carga;preservadas alterações paralelas de contabilidade e contas a pagar. + +Continuar pelos restantes selectores de origens ainda limitados,instalação comercial/recuperação do serviço periódico e validação de capacidade,relatórios,impressão e percursos por perfil. Consultas gerais de ocorrências/estornos deixam de ser uma lacuna de paginação;aprovações existentes não foram substituídas. Não reaplicar decisões ou autorizações anteriores nem declarar a aplicação inteira concluída por este incremento. +03/11/2026 — Balancete/razão contabilístico QA implementado sobre núcleo comum, com datas explícitas, paginação25/100 e saldo acumulado entre páginas. Ver `docs/relatorios-contabilisticos-ensaio.md`;21 verificações de serviço,20 HTTP,8 de isolamento e pacote QA novo fixado por hash. Preservados piloto8005, pacote/demos anteriores e decisões; sem activação operacional, novos direitos reais ou publicação. + + +## 03/11/2026 — autoridade das consultas partilhadas de correcções + +InventoryCorrectionPage passou a obter o utilizador actual da base,confirmar a empresa e reutilizar as competências nativas dos módulos também nas chamadas internas. Consulta de processos exige uma capacidade de leitura/preparação/conferência/execução do percurso;escolhas de recepções exigem preparação de ocorrências ou estornos;escolhas de operações exigem preparação de estornos. A permissão de consulta não passa a conceder preparação. Revogação/vigência/manutenção são reavaliadas mesmo com uma instância de utilizador anteriormente carregada. Não se alteraram perfis,sequências,alçadas ou comandos de aprovação/execução. + +Acrescentados quatro testes específicos de acesso para listas de origens e processos,com manutenção,expiração,leitor sem preparação e auditoria sem alterações. Regressão também cobre os cenários de paginação e correcções nativas anteriores. PHPStan sem erros e HTTP do piloto confirmado sem novos acessos ou decisões. Os resultados finais dos testes são registados após conclusão. Trabalho limitado à integração interna SGC;preservados SGF e o ciclo contabilístico paralelo. + +Validação final desta etapa:14testes/263verificações aprovados em InventoryCorrectionAccess e InventoryCorrectionPagination;PHPStan0erros,Pint,diff e HTTP do piloto aprovados. Não houve alteração frontend,nova execução PostgreSQL,bateria geral ou revisão visual nesta etapa. Provas nos logs inventory-access-tests.txt/inventory-access-phpstan.txt e inventory-pagination-http.json;resultados PostgreSQL anteriores não são nova execução desta alteração. + +## 03/11/2026 — pesquisa de saídas efectivas para necessidades planeadas + +Acrescentada pesquisa opcional por referência na acção de associar saída de uma reserva de planeamento. A pesquisa filtra antes da janela100 e vincula-se à identidade da necessidade,artigo e armazém;identidades de necessidade e saída validadas por empresa/tipo. O servidor usa a última versão e a elegibilidade nativa canManage(fulfil),recusando necessidades encerradas/canceladas ou sem competência. Leitores não recebem operações de preparação. Saídas estornadas ficam excluídas mesmo quando selected_issue_id tenta conservá-las;origens de outro artigo/local também não entram no conjunto. Sem campos de negócio novos ou nova concessão de tarefas. + +GET parcial actualiza operações,referência pesquisada e identidade de destino,preservando página,posicionamento,quantidade,justificação e selecção elegível. Enter no campo de pesquisa não submete a associação. Cada formulário mantém as próprias opções em memória,para uma pesquisa numa necessidade não apagar as escolhas de outra que esteja aberta. A saída escolhida fora dos resultados continua disponível apenas se pertence ao âmbito elegível. Continua a haver uma janela100 por pesquisa,sem alegar paginação universal do razão. Fonte e quantidade são novamente verificadas pelo comando nativo;associar não lança outra saída,não cria factura ou compra,não altera aprovações nem substitui a reserva física do armazém. + +Validação final54testes/971verificações aprovados em SupplyCoordination,InventoryReversals,SupplyPlanning e WorkflowTaskInbox. Três cenários dirigidos passaram também em PostgreSQL descartável85verificações,base removida. Incluem saída com100movimentos posteriores,pesquisa sem distinguir maiúsculas,selecção sem resultados,reutilização sem duplicar movimento,artigo/armazém,estado corrente,leitor sem preparação,empresa,comprimento do filtro e saída estornada que não reaparece pela selecção. A regressão conserva os percursos nativos de correcção,necessidades e tarefas. Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. + +HTTP8005 confirmou administração em leitura sem opções operacionais,recusas403 dos perfis sem acesso específico e validação do vínculo da pesquisa. Os casos positivos foram executados nas bases descartáveis;nenhum grant,reserva,associação,saída ou decisão novo no piloto. Provas reservation-source[-pg/-http].json e logs reservation-source-*. Sem migração,contactos externos,alteração SGF/8051,bateria geral ou revisão visual completa. Não somar execuções sobrepostas à regressão54. + +Continuidade:restantes selectores de novas origens históricas/políticas e de recepções/operações,instalação comercial e recuperação do serviço periódico,verificações de capacidade,relatórios/impressões e cobertura dos percursos documentados. A pesquisa de saídas para associação à necessidade deixa de ser uma lacuna. Preservar os motores e aprovações existentes e os trabalhos paralelos de contabilidade/contas a pagar;novas decisões operacionais do piloto permanecem sujeitas ao respectivo âmbito de autorização. + +## 03/11/2026 — pesquisa de análises aprovadas para políticas históricas + +A preparação de políticas com previsão histórica ganhou pesquisa opcional por período,usando o artigo,armazém e finalidade já escolhidos. O filtro aplica-se antes da janela100 de origens recentes,permitindo localizar uma análise antiga por datas. Fontes limitadas às últimas versões aprovadas e à empresa;identidades,âmbito,datas e ordem dos limites são validados no servidor. A origem seleccionada continua incluída fora do período pesquisado apenas quando ainda aprovada e do mesmo âmbito. Suspensão exclui-a sem alterar a referência histórica dos documentos existentes. + +GET parcial actualiza só as opções e a identidade do formulário,conservando dados,página e posição. Cada preparação/cópia mantém as próprias opções para uma pesquisa não apagar as escolhas de outra. Enter nos campos de pesquisa não submete a política. Não há novos campos de negócio obrigatórios,nem alteração da aprovação de análises/políticas. Qualidade,padrão,cobertura,assinatura do razão,actualidade e suspensão continuam a ser conferidos pelo motor nativo ao reutilizar a origem;encontrar uma opção não aprova a política nem altera a previsão de um documento já aprovado. + +Validação final53testes/1011verificações aprovados em SupplyHistory,SupplyPlanning,SupplySuggestions e SupplyPagination. Dois cenários específicos passaram também em PostgreSQL descartável62verificações,base removida. Cobertura:período fora das100escolhas recentes,selecção preservada,finalidade diferente,suspensão,empresa,âmbito incompleto,datas invertidas e leitor sem preparação,sem auditoria ou movimento gerado pela consulta. Tipos,lint,build,Pint,PHPStan0erros,diff e18hashes comuns aprovados. Provas history-source[-pg/-http].json e logs history-source-*. Não somar execuções sobrepostas nem declarar bateria geral. + +HTTP8005 confirmou pesquisa vinculada ao âmbito existente,preparação conforme o perfil e ausência de opções para quem só confere/consulta. Nenhuma análise,política,grant,decisão,stock,SGF ou ciclo contabilístico8051 alterado no piloto. Os casos positivos com períodos aprovados foram executados só em bases descartáveis. Sem revisão visual completa ou teste de carga. A janela100 de resultados por pesquisa permanece;isto não equivale a paginação universal de todos os selectores. + +Continuar pelos restantes selectores de políticas e origens de correcções,preparação comercial/recuperação do serviço periódico e validação de capacidade,relatórios,impressões e percursos. Preservar motores,aprovações,perfis e trabalhos paralelos;novas decisões operacionais não são concedidas por inferência. A pesquisa de análises históricas para políticas deixa de ser uma lacuna de acesso às origens antigas. + +## 03/11/2026 — pesquisa das políticas aprovadas para sugestões + +Preparação de sugestões ganhou pesquisa opcional por código/descrição do artigo e nome do armazém registados no snapshot aprovado. Filtragem sem distinguir maiúsculas,antes da janela100;seleccionados elegíveis mantidos fora dos resultados,sem reintroduzir política suspensa ou de outra empresa. selected_policy_key validado por empresa/tipo,consulta de origens apenas para supply_suggest_prepare. Leitores/conferentes não recebem escolhas de preparação. A pesquisa não selecciona fornecedor,aprova política ou gera compra. + +GET parcial actualiza só opções/texto/correlação do formulário,preservando página,posição,selecção e dados introduzidos. Cada preparação mantém as próprias opções para evitar interferência entre formulários;Enter na pesquisa não submete a sugestão. Origens aprovadas continuam sujeitas à basis nativa,à qualidade do histórico,à vigência,preço,tributação e revisão independente. Sem novos campos de negócio obrigatórios,sem alterar perfis,motores ou snapshots existentes. Janela100 de resultados por pesquisa permanece,não se declara paginação universal dos selectores. + +Validação final55testes/1077verificações aprovados em SupplySuggestions,SupplyHistory,SupplyPlanning e SupplyPagination. Dois cenários dirigidos em PostgreSQL descartável66verificações,base removida. Incluem política antiga fora das100recentes,caso sem resultados,selecção preservada,suspensão,empresa,comprimento/entrada com apóstrofo e conferente sem preparação;consultas sem auditoria,stock ou requisição novos e documento aprovado imutável. Fonte de volume construída com100políticas de artigos distintos pelos comandos nativos nas bases de teste. Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. + +HTTP8005 confirmou administração em leitura sem escolhas de preparação e recusas dos perfis sem acesso específico;cenários positivos só nas bases descartáveis. Provas policy-source[-pg/-http].json e logs policy-source-*. Sem grants,contas,sugestões,compras,movimentos ou decisões novos no piloto;SGF e demo contabilística8051 preservados. Sem nova bateria geral,revisão visual completa ou teste de carga. Não somar provas sobrepostas. + +Continuar pelas novas escolhas de recepções/operações nos percursos de correcção e restantes origens ainda limitadas,instalação comercial/recuperação do serviço periódico,capacidade,relatórios,impressões e cobertura dos documentos base. A pesquisa das políticas para sugestões deixa de ser uma lacuna de acesso a origens antigas. Preservar aprovações e autorizações anteriores e alterações paralelas,não declarar a aplicação inteira concluída por este incremento. + +## 03/11/2026 — pesquisa de novas origens de ocorrências e estornos + +Formulários de preparação passaram a procurar recepções pelo número e operações pela referência. Filtro sem distinguir maiúsculas aplicado antes da janela200;mantém fontes elegíveis já seleccionadas e fontes dos processos visíveis. Operações/linhas estornadas,tipos sem correcção física ou fontes de outra empresa não são reintroduzidos pela selecção. Direitos de preparação verificados pelo serviço nativo existente,com utilizador actual;leitores continuam sem opções. Parâmetros e identidades validados por empresa. + +Componente de pesquisa partilhado,opcional e recolhido,sem campos de negócio obrigatórios novos. GET parcial conserva dados,selecção elegível,página,filtros de processos e posição. Cada formulário mantém as suas opções para não interferir com outra preparação aberta. Enter não submete uma correcção. Seleccionar recepção continua a reutilizar artigo/armazém da origem;quantidades,saldo,unidades,lotes,independência e versão continuam a ser verificados nos comandos nativos. Nenhuma aprovação,saída ou correcção financeira executada pela pesquisa. Janela200por pesquisa continua;isto não é paginação universal das fontes. + +Validação final60testes/1158verificações aprovados em InventoryCorrectionPagination,InventoryCorrectionAccess,InventoryDispositions,InventoryReversals e WorkflowTaskInbox. Três cenários dirigidos em PostgreSQL descartável98verificações,base removida. Cobrem origens com200registos posteriores,pesquisa directa no serviço sem seleccionados,selecção preservada,estorno nativo e exclusão posterior,empresa,comprimento/entrada com apóstrofo e leitura sem preparação;sem mutações durante consulta,sem reescrever a recepção ou operação original. Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. Mantidos no runner os tempos dos modos anteriores de coordenação e qualidade de lotes. + +HTTP8005 confirmou os perfis existentes e ausência de novas escolhas operacionais para administração/armazém sem preparação;Compras/Finanças sem acesso específico403. Cenários positivos e decisões de correcção apenas em bases descartáveis. Provas correction-source[-pg/-http].json e logs correction-source-*. Sem grants,contas,correcções ou movimentos novos no piloto,sem alteração SGF/8051,contactos externos,bateria geral ou revisão visual completa. Não somar provas sobrepostas aos60testes. + +Continuar pelos restantes selectores de encomendas/requisições de fornecimentos,preparação comercial/recuperação do serviço periódico,capacidade,relatórios,impressões e cobertura integral dos documentos base. A pesquisa de novas recepções/operações para correcções deixa de ser uma lacuna de acesso às fontes antigas. Preservar autorizações existentes e trabalhos paralelos,não declarar a aplicação inteira concluída por este incremento. + +## 03/11/2026 — origens antigas no acompanhamento de fornecimentos + +A preparação de reservas planeadas e confirmações de entrega permite procurar, pelo número, requisições aprovadas e encomendas emitidas antes da janela das 100 mais recentes. Mantém as linhas elegíveis já seleccionadas e as origens dos registos visíveis. Uma selecção explícita não reintroduz requisições em rascunho, encomendas não emitidas ou dados de outra empresa. A linha continua a fornecer os dados canónicos, sem repetir cadastros ou alterar aprovações. + +Pesquisa opcional e recolhida, reutilizando o componente comum. Respostas parciais mantêm formulário, selecção, página e posição; cada preparação conserva as suas opções. Consultas não criam compras, recepções ou movimentos. Reservas planeadas e confirmações de entrega continuam distintas da reserva física e da recepção de stock. Mantém-se a janela de 100 fontes por pesquisa; não é paginação universal de selectores. + +Validação: 49 testes/1.047 verificações na regressão de coordenação, planeamento, paginação e caixa de tarefas; 3 testes/89 verificações em PostgreSQL descartável, removido no final. Cobertura inclui origem fora das 100 mais recentes, pesquisa sem distinguir maiúsculas, selecção preservada, utilização nativa da linha, exclusão de rascunhos, isolamento por empresa, leitura sem preparação e consultas sem mutações. Pint, PHPStan sem erros, tipos, lint e build aprovados. HTTP no piloto confirmou administração em leitura e recusas dos perfis sem competência específica, sem conceder direitos. + +Evidências: docs/proofs/supply-line-source.json, supply-line-source-pg.json e supply-line-source-http.json; logs supply-line-source-*. Nenhum comando de negócio no piloto, conta, grant, decisão de fornecedor ou alteração ao SGF. Sem nova bateria geral ou revisão visual completa. Não somar as provas sobrepostas. Permanecem a preparação comercial e recuperação do serviço periódico, capacidade, relatórios/impressões e conferência integral dos documentos base; este incremento não declara a aplicação concluída. + +## 03/11/2026 — acompanhamento nativo da verificação periódica + +sgc:replenishment-tick passou a gravar atomicamente o resultado técnico da própria execução em storage/framework/replenishment-command-status.json. Sucesso, trabalhos a recuperar e interrupção na descoberta/processamento ficam distinguíveis sem copiar pessoas, empresas, artigos, erros internos ou documentos. Falha ao escrever telemetria é reportada e avisada sem mascarar o resultado ou a excepção original. Limite inválido e composição não preparada não publicam sucesso. + +ReplenishmentQueue.status lê a observação nativa também fora do ambiente de avaliação. O ficheiro antigo do auxiliar permanece como fallback apenas no piloto e apenas se não existir observação nativa; não pode substituir uma falha nativa por sucesso. Dados danificados não certificam execução. Conservam-se a janela de actualidade, verificação da instalação e consulta das pendências duráveis; uma passagem bem sucedida não oculta trabalhos a recuperar. Isto observa passagens e não garante que um serviço continue activo depois delas. + +Validação final: 36 testes/406 verificações em ReplenishmentHeartbeat, WarehousePilotReadiness e AutomaticPurchases. Inclui sucesso, falha, excepção, limite inválido, ambiente comercial, prioridade sobre auxiliar, corrupção, obsolescência e recuperação existente. Ficheiros de teste isolados do piloto. Pint, PHPStan sem erros, diff e 18 hashes de execução aprovados. Prova docs/proofs/replenishment-heartbeat.json; logs replenishment-heartbeat-*. Não foi feita nova execução PostgreSQL, bateria geral ou revisão visual. Não há alteração de interface nesta etapa. + +Sem migração, comando de negócio manual no piloto, conta/grant, decisão de compra ou alteração SGF/8051. Não instalado serviço de arranque Windows: instalação comercial, recuperação após reinício, capacidade, relatórios/impressões e cobertura integral dos documentos continuam pendentes. Preservar o auxiliar existente e as aprovações; este acompanhamento não aprova nem encomenda e não constitui aceitação comercial da aplicação. + +## 03/11/2026 — exclusão local e recuperação do processo de reposição + +O comando nativo de reposição passou a adquirir um bloqueio de processo comum antes da descoberta/processamento, incluindo chamadas pelo auxiliar, agendador e operador. Usa posse do sistema operativo sem prazo que possa expirar a meio do lote. Uma segunda chamada não processa trabalhos, não substitui a observação concluída e devolve insucesso para não publicar falso sucesso através do auxiliar. Libertação em sucesso, excepção e terminação do processo; o ficheiro de controlo persistente não é sinal de bloqueio e não deve ser apagado durante execução. Não substitui transacções/recibos/idempotência existentes nem cria competências humanas. + +Regressão final 39 testes/425 verificações em ReplenishmentHeartbeat, WarehousePilotReadiness e AutomaticPurchases, mais 1 teste/5 verificações com processo PHP externo terminado pelo teste. Comprovadas recusa de concorrente, ausência de processamento/telemetria concorrente, libertação após erro e sucesso, destruição da instância e terminação externa sem apagar o ficheiro. Pint, PHPStan sem erros, diff e 18 hashes aprovados. Prova docs/proofs/replenishment-process-lock.json; logs replenishment-process-lock-* e replenishment-process-recovery-tests.txt. Sem nova execução PostgreSQL, bateria geral ou revisão visual; não somar estes resultados às regressões anteriores sobrepostas. + +Preparado docs/operacao-reposicao.md com configuração de tarefa recorrente, identidade técnica escolhida na instalação, directório inicial, diagnóstico, recuperação e conferência após reinício. Nenhuma tarefa Windows foi instalada nem credenciais copiadas. Bloqueio validado localmente, não como exclusão distribuída entre servidores. Sem comando manual de negócio no piloto, contas/grants, aprovações, decisões de fornecedor, migração ou alteração SGF/8051. Continuam pendentes instalação/ensaio comercial após reinício, capacidade, relatórios/impressões e cobertura integral dos documentos base. Não declarar aplicação concluída ou aceite comercialmente por este incremento. + +## 03/11/2026 — consulta filtrada do acompanhamento de fornecimentos + +Reservas planeadas, confirmações de entrega e alternativas técnicas passaram a permitir pesquisa opcional por código/nome do artigo, nome do local, referência da necessidade ou número da encomenda existente, com filtro de estado próprio de cada percurso. A consulta aplica filtros depois de seleccionar a versão mais recente de cada chave e antes de paginar25. Não ressuscita versões antigas activas de processos cancelados. Pesquisa sem distinguir maiúsculas, parâmetros limitados/validados e condições agrupadas dentro do âmbito da empresa. + +A interface mantém os filtros recolhidos quando vazios e não acrescenta campos de negócio obrigatórios. Nova filtragem reinicia a página; navegação conserva filtros. Pesquisas parciais de documentos/saídas continuam a preservar filtros, formulário e selecção. Ligação exacta de tarefa continua a apresentar a versão actual e histórico do processo, mesmo fora dos filtros. O estado é o registado; não deve ser confundido com pendência física calculada, especialmente nas reservas reabertas por estorno. Consultas não reservam/saem stock, recebem mercadoria ou alteram decisões. + +Validação: 54 testes/1158 verificações em SupplyCoordination,SupplyPlanning,SupplyPagination e WorkflowTaskInbox; 5 testes/111 verificações em PostgreSQL descartável, removido. Abrange origem antiga após26registos, exclusão de estado anterior, histórico preservado, filtros na página seguinte, foco exacto, artigo/local/encomenda, entradas com apóstrofo, empresa e ausência de mutações. Pint, PHPStan sem erros, lint, tipos, build e18hashes aprovados. HTTP no piloto conferiu leitura administrativa, validação e recusas dos perfis sem acesso específico, sem grants. Provas coordination-filters[-pg/-http].json e logs coordination-filters-*. + +Sem novas contas, decisões, comandos de negócio manuais no piloto ou alteração SGF/8051. Não houve revisão visual completa, ensaio de capacidade ou nova bateria geral; não somar provas sobrepostas. Continuam pendentes instalação/recuperação comercial após reinício, capacidade, relatórios/impressões e conferência integral dos documentos base. Este incremento melhora a consulta dos processos existentes; não declara novos relatórios ou conclusão integral da aplicação. + +## 03/11/2026 — exportação integrada do acompanhamento de fornecimentos + +Reservas planeadas, confirmações de entrega e alternativas técnicas passam a disponibilizar CSV a partir da responsabilidade da etapa. Exporta todos os resultados filtrados, sem limite da página25, ou apenas o processo em foco na sua versão actual. Reutiliza os mesmos direitos de leitura, filtros e consulta da página através de SupplyCoordinationRead, sem nova permissão ou segundo registo. Foco antigo resolve a chave e exporta a versão actual mesmo fora dos filtros, como a página; dados estrangeiros ou de outro tipo são recusados. + +CSV inclui momento da consulta, filtros, chave/versão/estado registado, artigo/local preservados, referência/encomenda, quantidade/unidade/data, quantidade por satisfazer calculada pelos serviços nativos, substituto/aplicação/validade e ligação exacta ao processo. Alternativas não recebem quantidades pendentes inventadas. Quantidade por satisfazer deve ser interpretada com o estado: não reactiva canceladas e não é reserva física ou compromisso de compra. Textos iniciados por fórmulas, incluindo espaços/controlos anteriores, são exportados como texto; delimitadores/aspas seguem CSV. Resposta privada sem cache, processamento por lotes100, transacção de leitura consistente em PostgreSQL. Não exporta automaticamente todo o histórico ou evidências confidenciais adicionais. + +Validação final58testes/1201verificações em SupplyCoordination,SupplyPlanning,SupplyPagination e WorkflowTaskInbox. Quatro testes/43verificações em PostgreSQL descartável, removido; cobrem todas as páginas, última versão, cancelamento, foco exacto, filtro, satisfação parcial, fórmula, empresa, manutenção/vigência e diferenças entre entrega/alternativa. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto confirmou exportações administrativas em leitura e recusas sem direitos específicos. Provas coordination-export[-pg/-http].json e logs coordination-export-*. Não somar regressões sobrepostas; não foi nova bateria geral, ensaio de capacidade ou revisão visual/impressão. + +Sem migração, conta/grant, comando de negócio manual no piloto ou decisão de fornecedor; SGF/8051 preservados. Permanecem a instalação comercial/ensaio após reinício do serviço periódico, capacidade, outros relatórios/impressões e conferência integral dos documentos base. Não apresentar este CSV de acompanhamento como autorização, documento de saída ou comprovação da conclusão integral da aplicação. + +## 03/11/2026 — prazos na caixa de tarefas integrada + +As caixas de tarefas de Compras/Armazém/Fornecimentos/Finanças e Pessoas permitem consultar todos os prazos, ultrapassados, com vencimento hoje, futuros ou sem prazo registado. Classificação de leitura em WorkflowTaskInboxController a partir exclusivamente de due_on da projecção nativa e da data do servidor, apresentada explicitamente como referência. Não copia tarefas para tabela, inventa prazos ou altera aprovações/atribuições. Documentos antigos continuam primeiro; o prazo não concede competência nem define prioridade automática. + +Contagens por prazo usam todas as tarefas autorizadas dentro do módulo e pesquisa, antes de filtrar prazo e paginar25. Cartões de módulos mantêm contagens globais anteriores. Formulário reinicia a página e paginação conserva filtros. Cada tarefa datada mostra a classificação, conservando ligação exacta à origem. A passagem do tempo actualiza a consulta sem novo movimento ou decisão; cancelamento nativo retira a tarefa sem reescrever histórico. Mantida projecção completa existente: este incremento não é ensaio ou optimização de capacidade. + +Regressão final87testes/2431verificações em WorkflowTaskInbox,SupplyCoordination e PeopleHiring. Dois cenários dirigidos em PostgreSQL descartável114verificações,base removida: limites de hoje/amanhã/ontem/sem data,28pendências antes da paginação,escopo módulo/pesquisa,contagens,parâmetros,passagem de dia e cancelamento sem mutações de consulta. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto conferiu cinco opções de prazo nos quatro perfis sintéticos existentes e recusa de opção inválida. Provas task-deadlines[-pg/-http].json e logs task-deadlines-*. + +Sem novos prazos configurados,contas/grants,decisões,comandos manuais de negócio no piloto,avisos externos ou alterações SGF/8051. Sem revisão visual completa,nova bateria geral ou ensaio de capacidade; não somar provas sobrepostas. Continuam pendentes instalação/ensaio comercial após reinício do serviço periódico,capacidade,outros relatórios/impressões e conferência integral dos documentos base. Esta consulta não aprova por silêncio nem constitui conclusão ou aceitação comercial integral do SGC. + +## 03/11/2026 — consulta e leitura contextual dos avisos internos + +Avisos de aprovação passam a ter pesquisa opcional pela referência da requisição, sem distinguir maiúsculas, sobre todos os avisos actualmente visíveis ao destinatário antes de paginar25. Mantidos filtros todos/por ler/intervenções/resultados e contador global de não lidos. Consulta usa transacção consistente somente de leitura em PostgreSQL, resposta privada sem cache e histórico Inertia encriptado. GET não cria nem lê avisos e não altera a elegibilidade nativa ou encaminhamento. + +Marcar como lido conserva vista,pesquisa e página em redireccionamento local construído apenas com parâmetros validados. Contexto inválido é recusado antes da marcação. A página é limitada à última válida quando a leitura retira a última linha do filtro; pesquisa nova reinicia a página. Mantidos comando nativo,controlo de empresa/destinatário,verificação actual de acesso e auditoria idempotente. Ler não decide nem submete requisição e não altera orçamento,stock ou etapa de aprovação. + +Regressão final24testes/757verificações em InternalRequisitionCandidate e WorkflowTaskInbox. Dois cenários dirigidos em PostgreSQL descartável142verificações,base removida. Cobertura de28avisos nativos,fonte antiga,pesquisa literal/entrada com apóstrofo,página/contexto preservados,contador global,consulta sem mutações,leitura repetida com uma auditoria e parâmetros inválidos sem leitura. Corrigidos no desenvolvimento a chamada encadeada do formulário e comparações do novo teste; só os resultados finais aprovados constituem esta prova. Pint,PHPStan0erros,lint,tipos finais,build,diff e18hashes aprovados. Artefacto compilado conferido sem chamada antiga encadeada. HTTP nos quatro perfis sintéticos existentes e quatro vistas,sem marcar avisos do piloto. Provas notice-consultation[-pg/-http].json e logs notice-consultation-*. + +Sem migração,contas/grants,leituras ou decisões de negócio novas no piloto;SGF/8051 preservados. Sem revisão visual completa,bateria geral ou ensaio de capacidade. Não somar provas sobrepostas. Permanecem instalação/ensaio comercial após reinício do serviço periódico,capacidade,outros relatórios/impressões e confronto integral com os documentos base. A pesquisa/readaptação dos avisos não transforma avisos históricos em tarefas actuais nem constitui conclusão integral do produto. + +## 03/11/2026 — confronto matriz/código/provas e pesquisa de acordos + +Matriz histórica de compatibilidade confrontada com código e provas posteriores em docs/matriz-sgc-reconciliada.md. Acrescentada referência no início de compatibilidade.md,conservando o histórico e separando piloto8005/SGFreal0.1.1/QA. Reposição,relatórios de Pessoas e correcções físicas não devem continuar tratados como globalmente ausentes; capacidades comuns/devolução desligadas não são activadas pelo facto de terem código/provas. Lacunas confirmadas incluem MFA não activo em Fortify e propostas ainda gravadas em MZN; restantes itens sem encerramento comprovado ficam explicitamente como tal. Separados desenvolvimento,instalação/configuração,validação e decisões humanas. Prova documental matrix-reconciliation.json fixa hashes das fontes consultadas,sem declarar nova execução dos testes antigos ou cobertura integral. + +Lacuna autorizada implementada: escolha de acordos no calendário deixava todos os acordos aprovados carregados integralmente e só filtrava artigo/local no navegador. Passou a pesquisa de referência/fornecedor no servidor,com âmbito artigo/armazém validado por empresa,100resultados recentes por pesquisa e preservação de fontes aprovadas utilizadas/seleccionadas. Suspensão e âmbito diferente não são contornados pela selecção. Mantido o conjunto de campos da resposta anterior,sem razões/auditoria adicionais. Validade,prazo conhecido,versão e aprovação da política continuam no motor nativo; a pesquisa não certifica elegibilidade contratual. Formulário conserva dados,página,foco e opções por preparação; pesquisa opcional recolhida,desactivada até escolher artigo/local,sem novos campos de negócio obrigatórios. + +Regressão final62testes/1381verificações em SupplyPagination,SupplyPlanning,SupplyCoordination e WorkflowTaskInbox;2testes/66verificações em PostgreSQL descartável,base removida. Inclui acordo fora das100fontes recentes,selecção conservada,utilização nativa da origem,exclusão após suspensão,empresa/âmbito,parâmetros/entrada com apóstrofo e leitor sem escolhas. Corrigida a data de criação das cópias sintéticas na preparação do teste; só resultados finais aprovados constituem esta entrega. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto confirmou os acessos existentes e âmbito obrigatório. Provas agreement-source[-pg/-http].json e logs agreement-source-*. + +Sem conta/grant,publicação de política,decisão comercial,comando manual de negócio no piloto ou alteração SGF/8051. Sem nova bateria geral,revisão visual ou ensaio de capacidade;não somar regressões sobrepostas. Próximas prioridades e limites estão na matriz reconciliada. Não declarar produto completo nem pedir nova autorização para decisões já autorizadas/aplicadas;seleccionar fornecedor/encomendar,activar capacidades ou instalar no destino continuam operações próprias. + +## 03/11/2026 — base nativa de autenticação de dois factores + +Confronto do código confirmou que a lacuna MFA ultrapassava configuração: User não usava TwoFactorAuthenticatable e o esquema não continha as três colunas mencionadas na documentação do modelo. Acrescentados trait/cast nativos e migração aditiva nullable para segredo cifrado,códigos de recuperação cifrados e confirmação. Não executada migração no piloto nem activada a feature. Nenhuma conta foi inscrita ou obrigatoriedade criada; roles/aprovações e política de acesso mantidas. + +Ecrãs Inertia portugueses para desafio por aplicação autenticadora/código de recuperação e confirmação de palavra-passe,usando exclusivamente endpoints/acções do Fortify instalado. Histórico encriptado,resposta privada sem cache e nenhuma chave/código de recuperação em props. Campos do formulário limpos após erro/sucesso. Limitador nativo de5tentativas/minuto por identidade em desafio/IP,ligado à configuração Fortify sem activar MFA. O formulário de gestão de adesão/confirmação/recuperação/desactivação ainda tem de ser concluído antes de propor activação; isto é base técnica,não entrega comercial integral de MFA. + +Regressão final25casos:23aprovados,2ignorados existentes por feature MFA desligada,426verificações. Os6cenários novos de base foram todos executados;6testes/60verificações em PostgreSQL descartável,base removida. Verificados segredo cifrado/omitido da serialização,feature desligada,conta confirmada em desafio,TOTP real do motor instalado,recuperação e recusa de reutilização sequencial,códigos inválidos/429,adesão não confirmada sem bloqueio de login e confirmação nativa da palavra-passe. Testes registam as rotas do próprio pacote apenas na aplicação descartável; corrigidas preparação do esquema e actualização dos nomes de rotas no teste antes dos resultados finais. Não é ensaio de recuperação concorrente,política obrigatória ou cobertura MFA completa de todas as rotas/composições. + +Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP nos perfis sintéticos de administração/armazém conferiu confirmação privada e desafio404 com MFA desligado,sem comandos de adesão ou mudança de palavra-passe. Provas mfa-foundation[-pg/-http].json e logs mfa-foundation-*. Sem SGF/8051,contas/grants,migração no piloto,decisões de compra,revisão visual completa,bateria geral ou aceitação comercial. A matriz reconciliada anterior conserva o seu instante/provas; agora há esta base técnica adicional,mas a pendência MFA não deve ser marcada como totalmente encerrada. + +Próxima continuidade:concluir interface de adesão protegida por palavra-passe,confirmação explícita do autenticador,consulta/renovação de recuperação e desactivação nativas,testar percursos e só então preparar eventual activação com migração e recuperação verificáveis. Não transformar autorização genérica de desenvolvimento em inscrição/obrigatoriedade das contas existentes. + +## 03/11/2026 — gestão voluntária MFA integrada na segurança da conta + +Concluída interface de adesão/retoma por chave manual,confirmação TOTP,consulta/ocultação/renovação de recuperação e cancelamento/desactivação explícita. Usa endpoints/acções nativos Fortify e o titular autenticado,sem campos de identidade paralelos. Segurança da conta exige confirmação recente de palavra-passe apenas quando MFA está deliberadamente activo. Preparação não confirmada não activa login;GET da página não modifica a preparação. Segredos não entram em props/estado recordado;respostas MFA privadas sem cache e códigos excluídos dos dados conservados após erro. Envio usa cabeçalho X-XSRF-TOKEN do cookie cifrado. Quando expira a confirmação,nova operação é recusada e a interface encaminha para reabrir segurança e confirmar palavra-passe. + +SGC_TWO_FACTOR_ENABLED documentado false em .env.example,configuração condicional com confirm/confirmPassword obrigatórios no mecanismo opcional. Não alterado .env,piloto ou SGF,nenhuma migração aplicada,conta inscrita,senha trocada ou grant. Obrigatoriedade por perfil e recuperação administrativa não foram introduzidas. Operação e limites em docs/mfa-adesao.md,com actualização da matriz reconciliada;não confundir código pronto com activação. + +Regressão final29casos:28aprovados,1ignorado existente,505verificações em gestão/segurança/autenticação/baseMFA/caixa de tarefas. O cenário de segurança com feature activa deixou de ser ignorado e é testado explicitamente. PostgreSQL descartável4testes/68verificações,base removida:adesão,propriedades sem chaves,códigos renovados distintos,desactivação recusada sem senha recente,isolamento do titular,código inválido sem activação/oldinput e CSRF nativo efectivamente activo recusando419 sem token e aceitando o cookie cifrado no cabeçalho. Corrigidas inferência do titular web e asserção da bolsa de erros nativa antes dos resultados finais. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP somente de leitura confirmou página de segurança sem opções MFA e endpoints inexistentes no piloto desligado. Provas mfa-management[-pg/-http].json e logs mfa-management-*. + +Sem revisão visual completa,ensaio de recuperação concorrente,bateria geral ou aceitação comercial;não somar provas sobrepostas à baseMFA anterior. Preparar operação concreta e salvaguarda/recuperação verificável antes de eventual pedido de activação;autorização de desenvolvimento não inscreve nem impõe MFA às contas existentes. Restantes lacunas da matriz e aprovações comerciais continuam separadas. + +## 03/11/2026 — exportação por processo dos metadados de auditoria + +Entregue CSV no painel Histórico da requisição,com metadados já visíveis:identidade,data,acção,utilizador e modo de execução. Página e exportação partilham RequisitionAuditTrail,sem segunda lista de eventos nem exportação do payload completo before/after. Consulta preserva empresa e Requisition.visibleTo,com actor web actual,manutenção/vigência verificadas antes de preparar e ao consumir a resposta. Não cria perfil de auditor global ou concede competências. Modo periódico conserva a atribuição técnica existente,sem inventar aprovação interactiva. + +Exporta todos os eventos disponíveis por identidade em lotes100,resposta privada sem cache e transacção PostgreSQL consistente/somente de leitura. CSV com BOM,tratamento de aspas/delimitadores e textos de fórmula neutralizados. GET e exportação não geram auditoria/recibo de leitura nem alteram documentos,etapas,orçamento ou stock. Fonte original e eventos imutáveis preservados. Âmbito e pendências em docs/auditoria-metadados-requisicao.md e matriz reconciliada:exportação geral,retenção,certificação,protecção SQL administrativa,volume e histórico paginado no ecrã continuam fora deste incremento. + +Regressão final35testes/1016verificações em InternalRequisitionCandidate,ProcurementReviewReport e WorkflowTaskInbox. PostgreSQL descartável2testes/139verificações,base removida. Testados105eventos adicionais ao evento nativo,sem corte na janela100,paridade com ecrã,ordem,exclusão de marcadores privados before/after,texto de fórmula,isolamento de requisitante/departamento/empresa,manutenção/vigência e comparação de registos antes/depois sem mutação. Corrigida referência ao identificador já desestruturado no componente antes da validação final de tipos/build. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP somente de leitura comparou detalhe/CSV da requisição sintética existente2 em quatro perfis e conferiu cabeçalhos/âmbito do conteúdo. Provas requisition-audit[-pg/-http].json e logs requisition-audit-*. + +Sem migração,contas/grants,decisões ou comandos manuais de negócio no piloto;SGF/8051 preservados. MFA e respectiva migração continuam desligados/não aplicados como antes. Sem nova bateria geral,revisão visual/folha de cálculo ou ensaio de capacidade;não somar regressões sobrepostas. Não declarar auditoria geral ou aplicação integralmente concluídas por esta exportação de metadados. + +## 03/11/2026 — histórico de requisições paginado e leitura coerente + +Histórico do detalhe da requisição passou a paginar25eventos por audit_page,validado1..1000000,conservando todos os registos e a ordem dos mais antigos primeiro. Metadados de paginação separados da lista,sem duplicar o payload. Ligações Inertia pedem apenas audit/auditPagination,preservam estado e posição e incluem âncora do histórico;preparações/decisões abertas não são reiniciadas apenas por mudar a página. A navegação parcial não decide processos;os comandos continuam a revalidar competências,origens e versões no motor nativo. + +Detalhe relê a requisição autorizada dentro de transacção consistente/somente de leitura em PostgreSQL,em vez de reutilizar apenas a fotografia do route binding obtida antes da transacção. Histórico Inertia encriptado. CSV existente continua integral e usa a projecção comum,sem herdar limite/página do ecrã. Sem novas competências,tabelas ou registos históricos. + +Regressão final35testes/1044verificações em InternalRequisitionCandidate,ProcurementReviewReport e WorkflowTaskInbox. PostgreSQL descartável2testes/167verificações,base removida:105eventos sintéticos adicionais à criação nativa,primeira/segunda/última página de6linhas,contagem integral,parâmetros inválidos,CSV completo,conteúdos privados excluídos,âmbito e comparação de registos sem mutações. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP somente de leitura no piloto confirmou encryptHistory,resposta parcial sem a requisição completa,lista até25,contador,recusa de página inválida,CSV e perfis sem acesso. Provas audit-pagination[-pg/-http].json e logs audit-pagination-*. Provas anteriores de metadados preservadas,sem as reescrever ou somar números sobrepostos. + +Sem migração,decisões,contas/grants,comandos manuais de negócio ou movimentos no piloto;SGF/8051 e activação MFA preservados. Matriz e documento de auditoria actualizados:paginação do histórico deixa de estar pendente,mas capacidade,visual/folha de cálculo,retenção,certificação e auditoria geral continuam sem encerramento por este incremento. Não houve bateria geral ou revisão visual completa e não foi declarada conclusão integral do SGC. + +## 03/11/2026 — indicador de necessidades por aprovar ligado ao relatório e CSV + +Relatórios de compras passaram a permitir attention=unapproved_needed,aberto directamente do indicador operacional ou pelo filtro opcional:estado draft/pending e data de necessidade anterior ao dia da consulta. Intersecta pesquisa,estado,departamento e período de criação existentes. Hoje/futuro e pedidos aprovados/rejeitados/cancelados não contam como necessidades por aprovar ultrapassadas. Data canónica apresentada na linha e acrescentada no fim do CSV,conservando as dez colunas anteriores. Não é prazo atribuído ao aprovador nem atraso comprovado do fornecedor e não cria tarefa/decisão por silêncio. + +Consulta/indicadores capturam o mesmo instante na leitura consistente;lista25,contagens sobre todos os resultados e CSV integral. Ao consumir a exportação,revalidados titular web,empresa,manutenção e funções actuais,construindo novamente a consulta dentro da transacção própria de leitura. Projecção de ordens recebe esse mesmo titular explícito em vez de depender de request global. Mantidos dinheiro exacto,conteúdo privado,CSV protegido e competências nativas. Revogação após preparar a resposta é recusada antes de emitir linhas. + +Regressão final29testes/726verificações em ProcurementReports,ProcurementReviewReport e WorkflowTaskInbox;PostgreSQL descartável2testes/71verificações,base removida. Testados27pedidos,duas páginas,CSV completo,hoje/futuro/estados fechados,parâmetros,passagem de dia,departamento e revogação no consumo do stream,sem mutações de consulta. Testes PostgreSQL confirmam fixtures apenas na base com nome guardado compras_receipts_test_<12hex> para exercitar a transacção própria;limpam schema temporário/funções entre casos,sem relaxar a salvaguarda da aplicação. Corrigida asserção da excepção encapsulada nativa do stream antes dos resultados finais. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto comparou contagem do indicador com lista/CSV nos perfis existentes. Provas needed-attention[-pg/-http].json e logs needed-attention-*. + +Sem migração,contas/grants,comandos manuais de negócio,novas datas de aprovação,decisões ou movimentos no piloto;SGF/8051 e activação MFA preservados. Matriz e docs/relatorios-necessidades-por-aprovar.md actualizados. Sem bateria geral,revisão visual,impressão ou ensaio de capacidade;não somar provas sobrepostas nem declarar relatórios/aplicação integralmente concluídos. + +## 03/11/2026 — contagens de aprovadas ligadas à consulta e exportação + +Relatórios passaram a abrir indicadores de aprovadas sem propostas registadas e aprovadas sem ordem visível,por attention=approved_without_quotes/approved_without_order. Campo Acompanhamento reúne as duas opções e a necessidade ultrapassada por aprovar,sem novos dados obrigatórios. Indicadores conservam filtros e regressam à primeira página. Consulta usa predicados SQL antes da paginação25 e CSV integral. Registo de proposta e ordem visível passam a consultas auxiliares comuns usadas tanto nos predicados como nas projecções de contagem/ordem,sem segunda regra de elegibilidade ou cópia de documentos. + +Mantidos acesso por empresa/departamento,actor explícito e controlo actual de competências/stream. Propostas expiradas continuam registadas para esta contagem;avaliação,elegibilidade,selecção e emissão permanecem nos motores/aprovações existentes. Os dois conjuntos sobrepõem-se e ausência de ordem visível não autoriza encomendar. Não foram criadas tarefas ou rascunhos pela leitura. + +Regressão final31testes/840verificações em ProcurementReports,ProcurementReviewReport e WorkflowTaskInbox. PostgreSQL descartável2testes/114verificações,base removida. Testados27aprovados sem documentos,proposta expirada registada sem ordem,pendente excluído,duas páginas,CSV completo,intersecção de estados,empresa/departamento e ausência de mutações nas fontes/orçamento/stock/auditoria. Corrigida apenas a obtenção da proposta da fixture no novo teste antes dos resultados finais. Mantido o isolamento PostgreSQL com guardas e limpeza do esquema temporário. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto comparou cada indicador com a lista/CSV para os perfis existentes,sem decisões. Provas approved-attention[-pg/-http].json e logs approved-attention-*. + +Matriz e docs/relatorios-aprovadas-sem-documentos.md actualizados. Sem migração,contas/grants,aprovações,escolha de fornecedor,emissão,movimentos ou comandos manuais de negócio no piloto;SGF/8051 e activação MFA preservados. Sem bateria geral,revisão visual,impressão ou capacidade;não somar provas sobrepostas nem declarar aplicação ou todos os relatórios completos. + +## 03/11/2026 — verificação técnica de saúde da reposição sem executar compras + +Entregue sgc:replenishment-health,com saída humana/--json e códigos0/1,sem descobrir origens,drenar fila,criar compras,aprovar ou actualizar heartbeat. Reutiliza observação e agregação de trabalhos de ReplenishmentQueue;consulta de consola sem identidade interactiva/sessão HTTP e transacção de leitura consistente em PostgreSQL quando própria. Resultado inclui somente instantes,motivos e contagens técnicas,não empresas/pessoas/artigos/erros privados. Falha de consulta produz indisponibilidade genérica e registo técnico. + +Separação explícita entre agregação global técnica e consulta por empresa,conservando inclusive o âmbito vazio de utilizador sem empresa. Nova passagem nativa associa o resultado à instalação/composição/base por hash,sem guardar nomes,caminhos ou credenciais. Associação diferente é rejeitada;legado sem associação mantém compatibilidade de leitura na interface mas não permite sucesso no novo comando. A verificação de saúde não reescreve/actualiza a observação para simular execução. Resultado técnico não é certificação de configuração funcional,processo activo futuro,assinatura do servidor ou aceitação comercial. + +Regressão final46testes/473verificações em ReplenishmentHealth,ReplenishmentHeartbeat,ReplenishmentProcessRecovery,WarehousePilotReadiness e AutomaticPurchases. PostgreSQL descartável6testes/43verificações,base removida. Cobrem leitura sem mutações de tabelas/telemetria,JSON/códigos de saída,passagem ausente/obsoleta/corrompida/falhada,pêndencias/falhas,duas empresas/sem empresa,composição diferente,recusa de identidade interactiva,excepção sem exposição e associação inválida/legada/correcta. Pint,PHPStan0erros,diff e18hashes aprovados. Sem alteração de frontend e sem alegar nova compilação ou revisão visual. HTTP só de leitura conferiu diagnóstico administrativo existente e recusa do perfil sem competência;comando de saúde executado apenas em bases descartáveis. Provas replenishment-health[-pg/-http].json e logs replenishment-health-*;provas históricas de heartbeat/processo preservadas,sem somar regressões sobrepostas. + +Matriz e docs/operacao-reposicao.md actualizados. Sem migração,contas/grants,decisões,comandos manuais de negócio no piloto,monitor recorrente ou instalação Windows;SGF/8051 e activação MFA preservados. Operação comercial após reinício,capacidade e restantes pendências da matriz continuam separadas. Não transformar este comando de consulta em prova de instalação,monitor activado ou aplicação concluída. + +## 03/11/2026 — entrega por bloco R1: recepção documental + +Adoptado o plano docs/plano-entregas-sgc.md: construir blocos funcionais e validar em conjunto no fecho, mantendo verificações críticas dirigidas durante a implementação. R1 acrescenta evidências privadas à recepção confirmada, reutilizando origem e responsabilidades nativas, com repetição segura, integridade na descarga, auditoria e navegação para o processo. Não repete stock, aprovação ou recepção física. + +Validação final: ReceiptDocumentBlockTest + ReceiptStockTest, 36 testes/271 verificações; PostgreSQL descartável 5/78, base removida. PHPStan sem erros, TypeScript, compilação de produção, Pint, diff e 18 hashes de execução conferidos. Prova docs/proofs/receipt-block.json e limites em docs/recepcao-documental.md. Sem revisão visual completa, carga ou instalação no piloto. Migração documental 2026_11_03_000002 pendente de autorização específica; não aplicar incidentalmente MFA. Sem novas contas, permissões ou decisões de negócio. R1 não declara todo o módulo Recepção concluído. diff --git a/docs/financeiro-consulta-paginada.md b/docs/financeiro-consulta-paginada.md new file mode 100644 index 0000000..e9ac08b --- /dev/null +++ b/docs/financeiro-consulta-paginada.md @@ -0,0 +1,13 @@ +# Consulta financeira paginada — composição sintética + +02/11/2026 — `NATIVE-PAYABLE-PAGED-READ-SIM-v1`. + +Implementados `SupplierPayablePage`, a selecção opcional no adaptador e validação de filtros no controlador. Origem financeira continua nas facturas/pagamentos/auditorias nativos; cálculo único no núcleo comum. Dependências lidas conjuntamente por página, com tenant e contexto Compras, sem bloqueios de escrita na leitura. Comandos conservam a revalidação original. O filtro da factura é conservado no formulário partilhado. + +`common-payables.paged_read_enabled` permanece **false**. Pacote instalado preservado; apenas QA usa explicitamente a fonte canónica em `sgf-tempo/packages/payables`. Activação com pacote antigo recusa 503. Nenhuma instalação, concessão, migração ou intervenção no piloto8005 decorre desta entrega. + +Relatório completo, metodologia, limites e números: [NATIVE_PAYABLE_PAGED_READ_DELIVERY.md](../../sgf-tempo/docs/NATIVE_PAYABLE_PAGED_READ_DELIVERY.md). Manifesto: [native-payable-paged-read-20261102.json](../../sgf-tempo/docs/proofs/native-payable-paged-read-20261102.json). + +Nove testes novos/113 asserções, 26 casos distintos afectados/183 asserções e 18 verificações HTTP aprovados; PHPStan nível7 sem erros. Em 10.000 facturas: integral122.903,707ms/80.060consultas/62MiB; página25 75,117ms/76consultas/28MiB; página100 91,089ms/76consultas/28MiB. Cenário: uma factura real do workflow sintético reconhecida e restantes cópias por reconhecer, não10.000dívidas. Posição original30MZN preservada, assim como histórico/stock/orçamento. Sem índices novos; uma amostra, não capacidade de implantação. + +Leitor concorrente terminou antes da libertação do escritor e ignorou auditoria não confirmada. Isto valida a separação da leitura, não uma bateria nova de decisões concorrentes. Revisão visual, composição/activação do pacote, carga sustentada e implantação continuam separadas. Preservar provas e configurações do piloto, alterações alheias e o limite Compras/Pessoas. Sem SGF remoto obrigatório. diff --git a/docs/matriz-sgc-reconciliada.md b/docs/matriz-sgc-reconciliada.md new file mode 100644 index 0000000..de6f721 --- /dev/null +++ b/docs/matriz-sgc-reconciliada.md @@ -0,0 +1,61 @@ +# Matriz reconciliada do SGC — 03/11/2026 + +Esta revisão confronta a matriz histórica de `compatibilidade.md` com código e provas posteriores. É uma reconciliação documental e de código, não uma nova execução de todos os testes, revisão visual ou aceitação comercial. As provas citadas conservam a data, composição e limites originais; os seus números não se somam. + +O âmbito é o SGC autónomo em `C:/laragon/www/compras`, com o piloto sintético existente em 8005. InvestMoz e a instalação real do SGF são distintos. A instalação real SGF permanece 0.1.1; desenvolvimento de adaptadores ou ensaios separados não autoriza implantação. As decisões do piloto já aplicadas não devem ser repetidas. + +## Confronto com a matriz histórica + +| Área da matriz | Código/prova consultados | Estado reconciliado e pendência | +| --- | --- | --- | +| Acesso e organização | `config/fortify.php`, `OrganizationAccess`, `CurrentActor`; `proofs/pilot-replenishment-activation-http.json` | Perfis, tarefas, contexto e vigência existem. Fortify activa registo e recuperação de palavra-passe; os campos MFA no modelo não provam MFA activo. MFA e validação comercial do ciclo de acessos continuam pendentes. Não impor regras novas às contas existentes por inferência. | +| Requisições e aprovações | `RequisitionWorkflow`; `InternalRequisitionCandidateTest`; `proofs/pilot-approval-activation.json` | Circuito versionado e recibos de comandos implementados; aprovação sintética aplicada uma vez. Valores e segregação continuam a pertencer à configuração da empresa. Não significa aceitação empresarial nem autorização de adjudicação. | +| Fornecedores | `SupplierParticipation`, `SupplierBankWorkflow`, `SupplierEvaluationWorkflow`; `proofs/supplier-quality-task-integration.json` | Cadastro, validação, avaliação qualitativa, contraditório e tarefas têm implementação posterior à matriz. Múltiplas contas bancárias e verificação externa não ficam comprovadas pelas provas aqui consultadas. | +| Cotações | `QuotationController`, `QuotationPricing`; `SupplierQuotationTest`; `proofs/pilot-evaluation-activation.json` | Preços/descontos reutilizam núcleo comum e as seis avaliações fictícias estão registadas. O controlador ainda grava moeda MZN: multimoeda de propostas continua uma lacuna real, distinta do registo de acordos de importação. Não inventar câmbios ou aprovar propostas novas. | +| Conflitos RF-AF-01 | `ConflictGuard`; `compatibilidade.md` | Há controlo no servidor. Não foi comprovado nesta revisão o ciclo completo de periodicidade/validade/levantamento; manter pendente, sem desactivar impedimentos. | +| Adjudicação e ordens | `routes/web.php`, `PurchaseAwardOrderTest`; `proposta-cancelamento-ordens.md` | Emissão e aprovações existentes preservadas. Cancelamento de ordem emitida continua descrito como proposta não activada; é distinto da revisão anterior à emissão e do estorno físico. Não cancelar nem libertar compromisso por analogia com uma correcção de stock. | +| Recepção | `ReceiptWorkflow`; provas de recepção e `proofs/inventory-corrections.json` | Recepção e correcções físicas têm implementação. Substituições/anexos e percurso operacional integral ainda exigem cobertura própria; correcção física não equivale a nota de crédito. | +| Stock | `StockWorkflow`, `InventoryLots`, `InventoryReversals`, `InventoryDispositions`; `proofs/inventory-task-integration.json` | Lotes, qualidade, correcções e tarefas já existem; «estornos ausentes» seria uma conclusão incorrecta para stock. Conversões gerais de unidade e revisão visual não foram encerradas. | +| Requisições internas | `WarehouseRequests`, `WarehouseReservations`, `WarehouseFulfilment`; `proofs/warehouse-task-pilot-status-http.json` | Autorização, reserva e entrega usam a requisição como documento de saída, sem factura. Venda a terceiros pertence à composição SGF; não desenvolver facturação paralela no SGC. | +| Devolução interna de excedente | `WarehouseReturns`; `warehouse-unused-return.md` | Implementação sintética própria, desactivada por defeito. Não confundir existência de código/prova com funcionalidade activa no piloto; não habilitar por esta revisão. | +| Equipamentos e peças | `EquipmentWorkflow`; `EquipmentSparePartsTest`, `InvestmentPolicyTest` | Cadastros e regras técnicas existem. Configuração empresarial, agregação por projecto e fraccionamento continuam sem encerramento comprovado nesta revisão. | +| Orçamento | `CommonBudget`, `BudgetWorkflow`, `BudgetReinforcementWorkflow`; `compatibilidade.md` e provas orçamentais | Núcleo comum, dotações, reforços, reservas e compromissos têm provas históricas. Reduções/transferências e aceitação operacional não devem ser inferidas dessas provas. | +| Facturas e pagamentos | `SupplierPayables`, `SupplierPayablePage`; `financeiro-consulta-paginada.md`, `contabilidade-ciclo-sintetico.md` | Há adaptadores e entregas comuns posteriores. Algumas capacidades continuam restritas a QA ou desligadas; não declarar estornos/notas universalmente disponíveis no piloto nem instalar pacote por inferência. | +| Planeamento e formalização | `PurchasePlanningWorkflow`, `ProposalOpeningWorkflow`, `FormalAdmission`, `FormalRecommendation`; `validacao-tecnica.md` | Preservar adopções e políticas independentes. Não há prova nesta revisão de assinaturas digitais verificadas, propostas seladas ou aprovação financeira de planos. | +| Pessoas e contratações | `PeopleHiring`, `PeopleRegistry`, `PeopleReportController`; `reutilizacao-dados.md` e `PeopleHiringTest` | Cadastro reutilizável, relatórios e caixa de tarefas são distintos dos de Compras. Uma referência antiga a «sem relatórios» não descreve o código actual. Aceitação visual e cobertura empresarial continuam separadas. | +| Navegação e relatórios | Páginas `workflow/tasks`, `workflow/notices`, `supply/coordination`; `proofs/task-deadlines.json`, `proofs/notice-consultation.json`, `proofs/coordination-export.json` | Filtros, prazos e CSV de acompanhamento entregues. Isto não encerra revisão visual, impressão ou todos os relatórios exigidos pelo produto. | +| Auditoria | `AuditLog`, auditoria dos comandos e relatórios por processo | Histórico existe. CSV de acompanhamento não é exportação geral de auditoria. Retenção, protecção face a administrador SQL e exportação integral continuam sem encerramento comprovado. | +| Reposição periódica | `AutomaticPurchases`, `ReplenishmentQueue`, `ReplenishmentProcessLock`; `proofs/replenishment-process-lock.json`, `operacao-reposicao.md` | Recuperação durável, reavaliação temporal e exclusão local existem. A pendência deixou de ser «implementar reposição»: é instalar/conferir operação comercial após reinício e validar capacidade. O bloqueio local não prova exclusão distribuída. | + +## Lacuna escolhida nesta revisão + +Na preparação de políticas do calendário, o controlador carregava integralmente todos os acordos aprovados, sem pesquisa de referência/fornecedor no servidor. O formulário filtrava apenas por artigo/local no navegador. Esta é uma lacuna de consulta e escala, não ausência de acordos nem falha das aprovações. + +Implementada pesquisa no servidor por referência/fornecedor e âmbito artigo/armazém, com janela de 100 resultados e conservação das fontes aprovadas já usadas/seleccionadas. O formulário conserva os dados e separa as opções de cada preparação. A validade, versão, prazo conhecido e decisão independente continuam a ser conferidos pelo motor nativo; aparecer no selector não certifica vigência contratual. Fonte suspensa não é reintroduzida pela selecção. Provas e resultado final desta entrega: `proofs/agreement-source.json` e variantes PostgreSQL/HTTP. + +## Pendências por natureza + +Actualização posterior de 03/11: entregue `sgc:replenishment-health`, consulta técnica sem processamento de compras, com resultado JSON/código de saída, associação da passagem à instalação e reutilização do estado da fila. [Operação](operacao-reposicao.md) e provas `proofs/replenishment-health.json`/variantes. Instalação do serviço Windows, monitorização recorrente, reinício e aceitação comercial continuam pendentes; a consulta não os executa ou certifica. + +Actualização posterior de 03/11: entregues as [consultas de aprovadas sem propostas registadas ou ordem visível](relatorios-aprovadas-sem-documentos.md), ligadas aos indicadores e CSV, com a mesma fonte e âmbito de leitura. Provas `proofs/approved-attention.json` e variantes. As contagens podem sobrepor-se e não representam decisões de elegibilidade, selecção ou emissão. + +Actualização posterior de 03/11: o indicador de necessidades ultrapassadas ainda por aprovar está ligado à lista filtrada e ao CSV, com a data já registada e revalidação do acesso à exportação. [Âmbito e evidências](relatorios-necessidades-por-aprovar.md), `proofs/needed-attention.json` e variantes. Não cria prazos de aprovação, decisões ou comprovação de atraso do fornecedor. + +Actualização posterior de 03/11: paginação do histórico da requisição entregue com 25 eventos por página, contagem integral e conservação dos formulários na navegação parcial. CSV integral preservado. Provas `proofs/audit-pagination.json` e variantes; não constitui validação de capacidade, auditoria geral ou aceitação visual. + +Actualização posterior de 03/11: entregue [CSV dos metadados do histórico por requisição](auditoria-metadados-requisicao.md), usando o mesmo âmbito e projecção do ecrã, com acesso actual, consulta sem mutações e conteúdo privado excluído. Provas `proofs/requisition-audit.json` e variantes PostgreSQL/HTTP. Exportação geral, retenção, certificação e protecção face a administradores SQL continuam sem encerramento por esta entrega. + +Actualização posterior de 03/11: a [adesão voluntária MFA](mfa-adesao.md) está agora implementada e testada com o Fortify nativo, incluindo interface, privacidade, recuperação e CSRF. Migração/activação não aplicadas ao piloto; obrigatoriedade empresarial, recuperação administrativa, revisão visual e aceitação continuam pendentes. A prova `proofs/mfa-management.json` sucede à preparação técnica `mfa-foundation.json`; não somar os resultados sobrepostos nem interpretar esta actualização como activação. + +1. Desenvolvimento: multimoeda de propostas, conversões gerais, restantes relatórios/impressões, exportação/retenção de auditoria e cobertura ainda não comprovada dos documentos base. Requisitos com parâmetros financeiros ou regras novas precisam de definição explícita; a matriz não os inventa. +2. Instalação/configuração: tarefa periódica comercial e ensaio após reinício; capacidades comuns ou devoluções desligadas continuam desligadas. Preparar operação exacta e evidências antes de pedir autorização para a instalação/activação concreta. +3. Validação: percursos, capacidade/carga sustentada, revisão visual, impressão e aceitação comercial. Código ou teste dirigido não substitui estas etapas. +4. Decisões humanas: seleccionar fornecedor, adjudicar, encomendar, alterar direitos ou parâmetros do piloto e intervir na instalação real SGF permanecem fora da autorização genérica de desenvolvimento. Não são bloqueio para desenvolver outras lacunas autorizadas. + +Não foi declarado o SGC completo. Esta tabela actualiza estados sustentados pelas fontes consultadas e identifica explicitamente o que continua por comprovar; não certifica cobertura integral de cada linha ou de todos os documentos originais. + +## 03/11/2026 — entrega por bloco R1: recepção documental + +Adoptado o plano docs/plano-entregas-sgc.md: construir blocos funcionais e validar em conjunto no fecho, mantendo verificações críticas dirigidas durante a implementação. R1 acrescenta evidências privadas à recepção confirmada, reutilizando origem e responsabilidades nativas, com repetição segura, integridade na descarga, auditoria e navegação para o processo. Não repete stock, aprovação ou recepção física. + +Validação final: ReceiptDocumentBlockTest + ReceiptStockTest, 36 testes/271 verificações; PostgreSQL descartável 5/78, base removida. PHPStan sem erros, TypeScript, compilação de produção, Pint, diff e 18 hashes de execução conferidos. Prova docs/proofs/receipt-block.json e limites em docs/recepcao-documental.md. Sem revisão visual completa, carga ou instalação no piloto. Migração documental 2026_11_03_000002 pendente de autorização específica; não aplicar incidentalmente MFA. Sem novas contas, permissões ou decisões de negócio. R1 não declara todo o módulo Recepção concluído. diff --git a/docs/mfa-adesao.md b/docs/mfa-adesao.md new file mode 100644 index 0000000..79a19d0 --- /dev/null +++ b/docs/mfa-adesao.md @@ -0,0 +1,25 @@ +# Adesão voluntária MFA no SGC + +03/11/2026. Código preparado e validado em bases descartáveis; piloto permanece com a funcionalidade desligada e sem a migração MFA aplicada. Não há inscrição automática, MFA obrigatório por perfil ou mudança de competências. + +## Percurso implementado + +Quando a capacidade estiver deliberadamente activa, Segurança da conta exige confirmação recente da palavra-passe pelo middleware nativo. A própria pessoa inicia a adesão, introduz a chave manual na sua aplicação autenticadora e confirma um código válido. Uma preparação ainda não confirmada não activa o desafio no login. Reabrir a página permite retomar ou cancelar a preparação, sem a modificar apenas por consulta. + +Depois da confirmação, a interface mostra os códigos de recuperação pedidos nesse acto. Pode voltar a consultá-los, ocultá-los, renová-los ou desactivar o segundo factor. Renovação e desactivação têm confirmação explícita na interface; os endpoints exigem novamente uma palavra-passe recente quando a sessão expira. A renovação invalida os códigos antigos. As operações usam o utilizador autenticado pelo Fortify, sem aceitar outro titular por parâmetro. + +As chaves e os códigos não entram nas propriedades Inertia ou no seu estado recordado. Ficam apenas no componente montado quando consultados, sem gravação deliberada em armazenamento do navegador. Respostas nativas MFA têm `private, no-store`; códigos de autenticação/recuperação são retirados dos dados de formulário conservados após erros. O cliente usa o cookie XSRF cifrado no cabeçalho e os mecanismos nativos de sessão e CSRF. A chave é introduzida manualmente; esta interface não inclui leitura de QR. + +## Estado de activação + +`SGC_TWO_FACTOR_ENABLED` está documentado no exemplo de configuração com valor `false`. Se activado numa instalação preparada, regista a funcionalidade Fortify com confirmação do autenticador e confirmação de palavra-passe. Isto disponibiliza adesão voluntária; não impõe MFA a todos os utilizadores nem define uma política empresarial obrigatória. + +A instalação exige previamente a migração `2026_11_03_000001_add_two_factor_columns_to_users`, a conferência da composição/configuração e o procedimento autorizado de salvaguarda/recuperação e caches. Nenhum desses passos foi aplicado ao piloto por esta entrega. Não alterar a chave de cifragem da aplicação para activar MFA. Desligar globalmente a capacidade também altera o percurso de autenticação; não serve como recuperação individual improvisada. + +Antes de propor activação no destino, preparar a operação concreta com estado da instalação e cópia de recuperação verificáveis, confirmar a identidade responsável pelo ensaio e testar a recuperação autorizada. A obrigatoriedade por perfil, eventual recuperação administrativa, concorrência de códigos de recuperação, revisão visual/acessibilidade e aceitação comercial continuam com cobertura própria pendente. O ensaio sequencial de recuperação não certifica reutilização concorrente. + +## Evidências + +`proofs/mfa-management.json` e variantes `-pg`/`-http` registam os resultados finais. A regressão inclui os cenários anteriores de desafio TOTP/recuperação e os novos de adesão, consulta sem mutação, renovação, desactivação, isolamento do titular, palavra-passe expirada, privacidade e CSRF com validação efectivamente ligada no teste. O piloto foi consultado apenas com a capacidade desligada: não foram feitas adesões, trocas de palavra-passe, migrações ou grants. + +Esta entrega não altera decisões, alçadas, reservas, stock ou aprovação de compras. A autenticação confirma a identidade; as competências continuam nos motores existentes. diff --git a/docs/operacao-reposicao.md b/docs/operacao-reposicao.md new file mode 100644 index 0000000..ef19a58 --- /dev/null +++ b/docs/operacao-reposicao.md @@ -0,0 +1,39 @@ +# Operação da reposição periódica do SGC + +## Estado desta entrega + +O comando, a recuperação durável, o diagnóstico e a exclusão de execuções locais estão implementados. Este documento não instala uma tarefa Windows nem comprova arranque após reinício. A configuração do cliente e o ensaio de reinício continuam a fazer parte da instalação. + +## Preparação da instalação + +Confirmar a composição SGC, a ligação à base da instalação, as migrações e a parametrização aprovada de artigos/armazéns. A conta técnica do sistema operativo precisa de ler a aplicação e de escrever em `storage` e `bootstrap/cache`; isso não lhe concede perfil funcional no SGC. Não transportar credenciais do auxiliar de avaliação para um cliente. + +No Agendador de Tarefas Windows, preparar uma tarefa recorrente a cada minuto, com execução mesmo sem uma sessão interactiva. Usar o executável PHP aprovado para a instalação, o argumento `artisan sgc:replenishment-tick` e a pasta da aplicação como directório inicial. Configurar a tarefa para não iniciar outra instância enquanto a anterior estiver em execução. Seleccionar a conta técnica e as suas credenciais no momento da instalação; não as gravar no repositório. Ainda não foi criada essa tarefa neste desenvolvimento. + +O comando específico evita iniciar tarefas não relacionadas com reposição. O limite de lote vem da configuração existente ou de `--limit=1..500`; não altera a quantidade de compra. Cada trabalho conserva na base a próxima data de avaliação, tentativas e sinal pendente. A frequência técnica não é prazo de aprovação nem autorização de compra. + +## Concorrência e recuperação + +O comando usa `storage/framework/replenishment-command.lock`, partilhado por chamadas manuais, auxiliar de avaliação e agendador nesta instalação local. A posse é mantida pelo sistema operativo até terminar o comando; não tem uma validade que possa expirar durante uma avaliação demorada. Uma segunda chamada não descobre nem processa trabalhos, não altera o último acompanhamento e devolve código de insucesso com mensagem de execução em curso. + +O bloqueio é libertado no final, em excepção ou quando o sistema operativo termina o processo. O ficheiro pode permanecer no disco: a sua existência não significa que esteja bloqueado. Não o apagar durante uma execução. Numa instalação com vários servidores e armazenamento não partilhado, este bloqueio local não constitui exclusão distribuída; esse cenário exige validação própria. As transacções e o controlo durável por empresa/trabalho continuam a proteger as compras independentemente deste controlo de processo. + +Depois de uma interrupção, a próxima execução recupera os sinais duráveis e reavalia as origens existentes. Uma transacção interrompida não deve deixar um rascunho parcial; uma compra já ligada à necessidade é reutilizada. Não apagar sinais ou repetir aprovações para forçar recuperação. Consultar as pendências e as mensagens técnicas quando existirem falhas sucessivas. + +## Conferência pelo responsável + +### Verificação técnica sem processar reposições + +Na pasta e ambiente já validados da instalação, `php artisan sgc:replenishment-health` consulta o estado; `php artisan sgc:replenishment-health --json` devolve dados estruturados. Código de saída 0 significa passagem recente confirmada para esta instalação, composição/esquema disponíveis e ausência de sinais pendentes/trabalhos falhados observados; 1 requer atenção. Não descobre origens, não drena trabalhos, não cria rascunhos e não actualiza a passagem observada. Usa a ligação configurada do operador técnico e não inicia sessão em nome de uma pessoa. + +JSON inclui momento da consulta, resultado, motivos, última passagem e contagens técnicas agregadas. Não inclui empresas, pessoas, artigos, mensagens privadas de trabalhos ou credenciais. Motivos possíveis: `not_installed`, `not_recent`, `last_run_not_successful`, `unbound_observation`, `pending_signals`, `failed_work` ou `unavailable`. Sinais pendentes podem ser transitórios entre passagens; o comando não atribui prioridade nem prazo de aprovação. A existência de regras válidas, responsáveis e configuração empresarial é conferida no diagnóstico funcional próprio, não certificada por este resultado técnico. + +Passagens nativas novas guardam uma associação por hash da pasta/composição/ligação à base, sem nomes de bases, caminhos ou credenciais no ficheiro. A leitura recusa uma associação diferente. Registos antigos sem associação continuam legíveis na interface para compatibilidade, mas não permitem sucesso na nova verificação de saúde. Apenas uma passagem real do comando de reposição grava a associação; consultar a saúde não a fabrica. Este hash evita confusão de contextos, não é assinatura ou certificado contra um administrador do servidor. + +As mesmas fontes de observação e contagem são reutilizadas no ecrã. A interface continua limitada à empresa do utilizador, incluindo utilizadores sem empresa; a agregação técnica global só é permitida em consola sem identidade interactiva ou sessão HTTP. Não foi instalado um monitor recorrente, tarefa de arranque ou serviço Windows. Continuam necessários os ensaios de instalação/reinício e a validação operacional do cliente. + +Em Reposição e no diagnóstico do piloto, verificar a passagem recente, o resultado e os trabalhos ainda a recuperar. `replenishment-command-status.json` é observação técnica, não documento de compra. Um estado antigo, danificado ou ausente não prova que o serviço esteja activo; uma passagem bem sucedida também não garante execução futura. Uma terminação abrupta pode deixar a última observação até esta perder actualidade. + +No ensaio de instalação, comprovar passagem sem utilizador ligado, reinício do serviço/máquina, recuperação da pendência e ausência de duplicações, usando dados sintéticos autorizados. Conservar os resultados e os registos protegidos segundo a política do cliente. Não confundir a passagem do auxiliar local com aceitação comercial ou instalação concluída. + +O serviço prepara e recupera necessidades/rascunhos segundo a configuração existente. Não submete decisões humanas, selecciona fornecedores, emite encomendas ou movimenta stock em substituição dos percursos aprovados. diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md index 539c691..0245a9b 100644 --- a/docs/orientacoes-continuidade.md +++ b/docs/orientacoes-continuidade.md @@ -662,3 +662,412 @@ Aplicação atómica por OrganizationConfiguration, sob bloqueio da empresa e re **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. + +## 02/11/2026 — testes PostgreSQL e leitura financeira comum + +Ao esperar uma violação de trigger em testes PostgreSQL, usar DB::transaction/savepoint para conter a falha antes da asserção seguinte. Não mascarar uma transacção abortada como a recusa funcional pretendida. O modo financial-read-affected do runner SGF exige base QA nova por execução, pois usa RefreshDatabase; não retomar sobre uma base com provas conservadas nem reparar funções/tabelas por arrasto.21 casos/144 asserções de histórico/decisões foram consolidados, preservando o diagnóstico intermédio e seleccionando só os casos aprovados. + +A consulta financeira paginada foi ligada ao adaptador SGF, não ao adaptador autónomo SGC. Reutiliza o cálculo/cadeia de decisões existentes com dados de leitura em lote, sem snapshots de consulta nos comandos. Não copiar um segundo saldo ou activar pacotes/acessos no SGC por consequência da prova SGF. Permanecem separados origem histórica viva/cutover, revisão visual e implantação; ver o relatório SGF_PAYABLE_PAGED_READ_DELIVERY.md. +## 02/11/2026 — continuidade das tarefas formais e de fornecimentos + +Admissão/recomendação e respostas documentais próprias pendentes integradas nas tarefas com autoridades partilhadas pelos comandos/formulários. Preservar política/quórum adoptados, bases/versões, empresa, departamento, manutenção, conflitos e independência. Não inventar analistas obrigatórios nem repetir respostas alheias; uma avaliação concluída desfavorável não é pendência de preenchimento. Focos exactos conservam a história e não tornam uma tentativa antiga executável. + +Fornecimentos/reposição tem categoria própria nas tarefas. Conferência de acordos/políticas/regras exige competência e aprovação independente de todos os preparadores anteriores. Suspensão de regra automática conserva a autoridade original; devolução da política continua possível quando a origem mudou. Formulários apresentam acções por elegibilidade canónica. Foco de ligação antiga mostra a última versão com aviso. Fila completa e paginada, sem corte100; listagem geral de fornecimentos continua limitada100. Consultar não decide nem persiste tarefas num segundo motor. Pessoas e SGF mantêm os seus limites. + +Provas aprovadas formal156/2860+PostgreSQL11/383; fornecimentos48/838+PostgreSQL8/262; suites sobrepostas, não somar. Tipos/lint/build/análise PHP/formatação/18hashes e HTTP8005 passaram. Nenhuma nova bateria geral/revisão visual completa/migração/conta/grant/decisão operacional persistente. Provas formal-task-integration[-pg/-http].json e supply-task-integration[-pg/-http].json. + +Proposta operacional pendente em pilot-replenishment-proposal.json: artigo fictício existente ART-TEST/ARM-TEST com1cx, publicar catálogo, mínimo1/alvo3/máximo3cx, estimativa artificial10MZN eIVA16%, regra até09/11/2026; requisitante9/OPS1, preparação11, conferência12, publicação13. Simulador exclusivo de cópia restaurada descartável confirmou1rascunho de2cx/23,20MZN, sem duplicação após repetir aprovação e reavaliar duas vezes, sem submissão/aprovações da compra nem movimento. Contas/grants/ordens/aprovações/stock preservados na cópia; contexto sintético reproduzido apenas em runtime. Cópia/base eliminadas; prova pilot-replenishment-dry-run.json vinculada ao hash. O simulador não aplica no piloto. + +Solicitar autorização apenas para a aplicação concreta dessa publicação/política/regra e criação do rascunho sintético, conforme exclusão expressa na autorização anterior de acessos (AGENTS.md). Não pedir novamente distribuição dos perfis27. Antes de eventual aplicação, conferir hash, validade, stock livre/movimentos, versão do catálogo e acessos; se houver mudança, rever a proposta sem forçar valores antigos. Não há autorização por inferência para SGF, contexto comercial, aprovação/ordem/entrega ou contas novas. Execução contratual e restantes tarefas especializadas não estão declaradas completas. + +## 02/11/2026 — reposição aplicada, tarefas históricas e instalação de comandos + +Proposta de reposição foi expressamente autorizada e aplicada em8005. Publicação13/preparação11/conferência12/requisitante9/OPS1,ART-TEST/ARM-TEST,mínimo1/alvo3/máximo3cx,estimativa10MZN/IVA16%,regra até09/11/2026. REQ-2026-000002,id2,rascunho2cx/23,20MZN; stock1cx intacto. Não reaplicar nem reabrir autorização. Artefactos originais pendentes são história; estado efectivo em pilot-replenishment-authorization/activation/activation-http.json. Backup consistente/restauro112tabelas,106tabelas protegidas conferidas; cópia privada preservada,restauro descartável eliminado. Sem contas/grants/aprovação da compra/ordens/SGF. + +Histórico de consumo ligado às tarefas com competência própria supply_history_review e autoridade partilhada de SupplyHistory. Conferência independente de todos os preparadores; origem alterada bloqueia aprovação mas permite devolução; confirmação humana de âmbito permanece. Foco record_id valida tipo/empresa e mostra versão actual com aviso. A fila não perde pendências após100; o ecrã geral mantém limite100. Não atribuir histórico ao perfil supply_review por inferência nem criar análises no piloto por consequência dos testes. Provas59/1030 regressão,11/130 diagnóstico,7/193 PostgreSQL; suites sobrepostas,não somar. Tipos/lint/build/análise PHP/Pint/18hashes/HTTP passaram. Sem revisão visual completa ou bateria geral. + +Foi detectada e instalada a migração técnica de recibos/versões 2026_11_01_000046_add_requisition_command_versions, que faltava na QA apesar de create_workflow_notices ter prefixo semelhante. Antes da instalação, comprovada na cópia; instalação guardada/atómica com novo backup/restauro112tabelas,110tabelas protegidas e requisições preservadas excepto process_version inicial1. Tabela nova vazia e2triggers,sem submeter/aprovar compra. Prova pilot-command-receipts-installation.json. Diagnóstico agora exige tabela/coluna; HTTP actualwarehouse/replenishment_scenario_ready=true eclient_configuration_complete=false. Não declarar entrega comercial ou outorgar contexto contratado. Não repetir a instalação. + +Próximo passo de negócio pendente de autorização específica: submeter apenasREQ-2026-000002 por9,sem mudar2cx/23,20MZN nem aprovar. Proposta pilot-submission-proposal.json; cópia restaurada final em pilot-submission-dry-run.json confirmou encaminhamento ao chefe10, todas as aprovações pendentes, repetição idempotente e preservação de artigos/valores/stock/ordens/acessos. Base/dump temporários removidos. A prova final não precisou instalar migração porque esta já estava na origem; o ensaio anterior de instalação ocorreu antes da aplicação técnica. Não executar submissão por inferência da autorização de publicação/política/regra. Continuar funcionalidades correntes sem pedir confirmação técnica; sugestões de reposição e execução contratual continuam lacunas distintas. + +## 02/11/2026 — consulta financeira nativa paginada, somente QA + +Entregue NATIVE-PAYABLE-PAGED-READ-SIM-v1: adaptador sobre facturas, linhas, auditorias e pagamentos originais lidos conjuntamente por página; cálculo único no pacote comum, filtros/totais no servidor, factura específica conservada e leitura sem bloqueio de escrita. Tenant, contexto Compras, validade/manutenção e competências revalidados. Comandos usam fontes actuais. Configuração paged_read_enabled=false e pacote instalado preservados; QA usa explicitamente a fonte canónica. Pacote antigo com paginação recusa503. Sem activar piloto8005 ou SGF remoto obrigatório. + +Provas:9testes novos/113asserções;26casos distintos afectados/183asserções (25válidos reutilizados e um alvo corrigido),18verificaçõesHTTP,PHPStan7 sem erros. Em10.000facturas: integral122.903,707ms/80.060consultas/62MiB; página25 75,117ms/76consultas/28MiB; página100 91,089ms/76consultas/28MiB. Uma factura de workflow com obrigação e três pagamentos, restantes cópias por reconhecer; não10.000dívidas. Sem índices novos. Leitor terminou antes da libertação do escritor, sem observar auditoria não confirmada. Original30MZN, stock/orçamento e estados preservados. Ver docs/financeiro-consulta-paginada.md e manifesto SGF docs/proofs/native-payable-paged-read-20261102.json. + +Preservar provas válidas, seis registos protegidos, cache bloqueada, contas/configuração/piloto e trabalho alheio. Sem novas concessões/migrações/pagamentos/publicação/checkpoint. Revisão visual, pacote/activação, carga sustentada e capacidade de implantação continuam separadas. Os registos anteriores sobre paginação apenas SGF descrevem o estado histórico antes desta integração QA; não significam activação no destino. Eventuais índices exigem comparação separada em cópia sintética, sem aplicação por arrasto. + +## 02/11/2026 — submissão aplicada e sugestões ligadas às tarefas + +Submissão de REQ-2026-000002 autorizada por «autorizo» e aplicada às19:20:53UTC por9. Estado pending, único responsável da etapa actual10,2cx/23,20MZN preservados, reserva canónica2320cêntimos/compromisso0 e stock1cx. Provas pilot-submission-authorization/activation/activation-http.json; backup/restauro113tabelas e106tabelas protegidas, restantes requisições/orçamentos/âmbitos da fila conferidos. Duas tentativas anteriores reverteram integralmente pelo verificador antes de reconhecer a reserva e sinalização normais da submissão. Não repetir nem reabrir esta autorização. A compra continua sem aprovação, encomenda ou movimento; SGF e contas/grants preservados. + +Sugestões manuais agora encaminham conferência independente, conversão por requisitante diferente do conferente e encerramento após rejeição ou recepção completa. Reutilizam SupplySuggestions, dados aprovados e ReceiptWorkflow; não duplicar motores ou conceder competências por arrasto. Foco record_id valida empresa/tipo e apresenta versão actual com aviso. Fila completa/paginada; visão geral ainda limitada100. Aprovação/conversão revalidam fontes; consultar não decide. Não criar sugestões/grants no piloto por consequência dos testes. + +Provas novas50/1024na regressão dirigida,3/142PostgreSQL descartável; tipos/lint/build/PHPStan/Pint/18hashes/HTTP passaram. Suites sobrepostas,não somar. Sem nova bateria geral/revisão visual completa/migração. Encerramento novo testado pela rejeição; critério de recepção completa reaproveitado,sem novo percurso completo de recebimento. Artefactos suggestion-task-integration[-pg/-http].json. + +Próxima decisão pendente: pilot-approval-proposal.json propõe apenas aprovar a mesma requisição por10, conforme etapa existente, abrindo consulta ao mercado pelo Procurement11. Cópia descartável em pilot-approval-dry-run.json confirmou repetição idempotente, estado approved, tarefa de consulta, reserva2320/compromisso0 e preservação de contas/artigos/valores/stock/ordens. A aprovação da requisição não converte reserva em compromisso; esse pressuposto inicial de teste foi corrigido antes da prova final, sem alterar o motor. Não executar aprovação por inferência da autorização de submissão; não seleccionar fornecedor, adjudicar, encomendar ou entregar. Continuar desenvolvimento/testes correntes sem confirmação técnica, preservando trabalhos de outras sessões. + +## 02/11/2026 — aprovação aplicada, alternativas técnicas e cenário de consulta preparado + +REQ-2026-000002 aprovada sinteticamente por10 às19:34:34UTC mediante autorização expressa; estado approved,2cx/23,20MZN,reserva2320/compromisso0,stock1cx intacto. Tarefa de consulta no Procurement11. Não reaplicar nem pedir novamente esta autorização. Sem selecção/adjudicação/encomenda ou novas contas/grants/SGF. Provas pilot-approval-authorization/activation/activation-http.json. Uma troca de actor na ferramenta antes do callback pós-commit gerou403 após a decisão estar confirmada; a rotina foi corrigida e o recibo único50990058-e957-4eb2-a5c6-271d3ea63bda foi verificado sem replay. Restauro original113tabelas,107tabelas protegidas e demais requisições conferidos; backup privado preservado e restauro temporário removido. Não confundir falha de verificação pós-commit com decisão revertida. approved_at identifica a decisão; verified_at a conferência posterior. + +Alternativas técnicas encaminhadas nas tarefas e formulário pela autoridade partilhada SupplyCoordination. Exige supply_technical_review e independência de todos os preparadores; supply_review não concede essa função. Compatibilidade pendente/origem alterada não são aprovadas silenciosamente; devolução preservada. Validade,sentido,aplicação e especificações continuam no motor. Aprovar análise não substitui artigo,selecciona fornecedor ou movimenta stock. Foco record_id valida empresa/tipo e mostra versão actual com aviso. Fila completa/paginada; listagem geral mantém limite100. Sem concessões ou alternativas persistentes por consequência do desenvolvimento. + +Provas55/1012na regressão dirigida e3/110PostgreSQL descartável,mais tipos/lint/build/PHPStan/Pint/18hashes/HTTP. Suites sobrepostas,não somar.0alternativas no piloto; pendências e decisões testadas só em bases descartáveis. Sem revisão visual completa/nova bateria geral. Artefactos alternative-task-integration[-pg/-http].json; preservar trabalho paralelo alheio. + +Próximo cenário operacional preparado e ainda não autorizado: pilot-market-proposal.json. A consulta tem apenas1fornecedor elegível e mínimo3propostas já preservado. Propõe2cadastros fictícios adicionais (NUITs de ensaio990000021/990000022,example.test),preparação11/activação12,mais3propostas inteiramente artificiais de2cx,preços10/11/12MZN,IVA16%,totais23,20/25,52/27,84MZN,prazos2/3/4dias,validez09/11/2026. Sem alterar requisição,mínimos ou perfis; sem contactos externos,avaliações,selecção,adjudicação,ordem ou SGF. Não apresentar os identificadores/condições como identidades verificadas ou ofertas reais. + +Prova pilot-market-dry-run.json em cópia restaurada passou pelas validações/controladores canónicos,com cadastro anterior e tabelas protegidas intactos,0avaliações e nenhuma selecção/encomenda. Base/dump temporários removidos. O erro inicial de nome de tabela no verificador foi corrigido para a relação de avaliações canónica; só a repetição final é prova aprovada. Pedir autorização para estes novos cadastros e valores fictícios; não os criar por inferência da aprovação da requisição. Desenvolvimento técnico independente continua autorizado; execução contratual e restantes tarefas especializadas não estão declaradas concluídas. + +## 02/11/2026 — consulta aplicada, cadastros encaminhados e avaliação preparada + +Cenário de consulta expressamente autorizado e aplicado em8005 às19:56:43UTC: dois fornecedores fictícios2/3 preparados11/validados12,existente1 reutilizado,três propostas2/3/4 com totais23,20/25,52/27,84MZN. Avaliações0; sem selecção,ordem ou comunicação externa. Requisição/stock1cx/reserva2320/compromisso0/contas/grants/SGF preservados. Provas pilot-market-authorization/activation/activation-http.json,backup/restauro113tabelas,109tabelas protegidas. Driver comum scripts/support/pilot-market-scenario.php ensaiado após extracção e usado na aplicação,sempre com validações/controladores nativos. Não reaplicar ou pedir novamente autorização. Os cadastros e NUITs são fictícios,não validação fiscal/diligência comercial real. + +Cadastros pending agora chegam às tarefas por SupplierParticipation,com Finanças independente do criador/último editor e controlo de versão. Preparação/edição/conferência usam actor actual sob bloqueio da empresa; manutenção não executa e papel revogado após validação não persiste pela instância antiga. Formulário/política/tarefas partilham autoridade. Activação de ficha impedida/vencida continua bloqueada; suspensão permitida pelo percurso anterior. Fichas activas/suspensas não são novas pendências. Não criar atribuições periódicas por inferência. Ficha privada/no-store,história cifrada; DTO sem dados fiscais/contactos/beneficiários/documentos. Fila completa/paginada e foco canónico,sem segundo motor. + +Provas94/1276regressão dirigida,1/2actor revogado,1/66ficha privada e5/153PostgreSQL descartável. Primeira prova4/151anterior ao caso adicional; não somar suites sobrepostas. Tipos/lint/build/PHPStan/Pint/18hashes/HTTP passaram. Privacidade da ficha foi confirmada por caso dirigido/HTTP/análise estática após a regressão ampla,sem declarar repetição dessa regressão.0pendências actuais de cadastro,nenhuma nova conta/grant/migração/decisão por consequência do desenvolvimento; sem revisão visual completa/bateria geral. Artefactos supplier-task-integration[-pg/-http].json. + +Próxima proposta pendente: pilot-evaluation-proposal.json,seis avaliações fictícias por12e10 das propostas2/3/4,notas neutras5/10 em qualidade/histórico/pagamento. Reutilizar preço,prazo,pesos e mínimo de dois avaliadores já preservados. Cópia restaurada em pilot-evaluation-dry-run.json confirmou seis registos,duplicação recusada,comparação pronta e tarefa de conferência humana,sem seleccionar/adjudicar/encomendar ou alterar contas/cadastros/propostas/stock/orçamento. Base/dump removidos. Corrigido import inicialmente ausente no simulador; só execução final é prova aprovada. Não persistir avaliações por inferência: a autorização de consulta excluiu expressamente avaliar. Continuar desenvolvimento/testes correntes sem confirmação técnica,consolidando restantes percursos em separado. + +## 02/11/2026 — tarefas do armazém consolidadas sem executar operações no piloto + +Continuação técnica não autorizou as seis avaliações pendentes. Não repetir essa solicitação por cada incremento nem bloquear desenvolvimento independente; nenhuma etapa operacional anterior foi reaplicada. QA mantém três propostas sem avaliações, requisição aprovada23,20MZN e stock1cx, confirmado em warehouse-task-pilot-status-http.json. + +Integradas tarefas de conferência/execução de devolução,abate e estorno integral de operação/recepção. Reutilizar InventoryDispositions/InventoryReversals/StockWorkflow; InventoryReviewParticipation partilha só elegibilidade, sem outro workflow/escritor. Decisão independente de todos os preparadores; execução por pessoa diferente do conferente, conservando a eventual execução pelo preparador permitida no motor anterior. Aprovar/consultar não movimenta. Repetição não duplica físico; originais preservados; correcção financeira permanece separada. executionSourceCurrent usa basis nativo: origem/saldo inválido bloqueia confirmação no ecrã, mantém tarefa do operador e encaminha conferente para devolução autorizada, sem decisão automática. O escritor revalida as condições finais. + +Foco record_id valida empresa/tipo e mostra última versão com aviso; fila completa/paginada25,além do limite100das visões gerais. Actores/formulário/tarefas usam a mesma autoridade. Respostas privadas/no-store,história cifrada; sem novas competências, nomes, prazos ou campos repetidos da origem. + +Lotes: encaminhamento apenas da conferência de configuração/revisão inventory_lot_review. Manter controlo anterior em vigor durante preparação pendente e preservar verificações de saldo/stamp/origem. Activar não movimenta/liberta qualidade nem duplica repartição na revisão. Não foram criadas tarefas de hold/release/identificação ou nova regra de qualidade. Preparações paralelas ultrapassadas por controlo activo não aparecem executáveis; registo histórico permanece. Reprodução inicialmente falhou, correcção aplicada só na elegibilidade segundo previous_setup_id nativo. Visão geral ainda limitada100por consulta,histórico200; foco/fila não significam histórico integral. + +Provas consolidadas86/1244na regressão e10/441PostgreSQL de correcções. Filtro final de configuração obsoleta testado depois por6/199SQLite e6/199PostgreSQL, sem declarar repetição dos86após essa pequena mudança. Bases removidas,suites sobrepostas,não somar. Corrigido no teste o pressuposto de comparação de atributos antes de hidratar; originais continuam imutáveis. Tipos/lint/build/PHPStan/Pint/18hashes/HTTP passaram; sem bateria geral/revisão visual completa. Artefactos inventory-task-integration[-pg/-http].json e lot-task-integration[-pg/-http].json. + +Somente consultas no piloto8005:0ocorrências/estornos/configurações de lotes,nenhuma nova conta/grant/migração/movimento/avaliação/seleção/ordem/SGF. Administração consulta; armazém conserva execução nativa de correcções,sem outorga adicional de preparação/conferência/lotes. Continuar demais encaminhamentos nativos e consolidação sem inventar regras ou declarar toda a proposta concluída. As avaliações do piloto mantêm autorização pendente própria; preservar trabalhos de outras sessões. + +## 02/11/2026 — seis avaliações autorizadas e tarefas de medidas de melhoria + +Autorização expressa do proprietário aplicada uma única vez em8005 às21:09:06UTC: seis avaliações fictícias das propostas2/3/4 de REQ-2026-000002,pelas contas existentes12(Finanças)e10(chefe de departamento),com5/10 em qualidade,histórico e pagamento. Registos1a6;comparação pronta e tarefa de conferência humana para Compras11. Sem seleccionar,adjudicar,encomendar ou comunicar externamente. Provas pilot-evaluation-authorization/activation/activation-http.json;proposta original preservada pelo SHA9c6f5004c81030e1ea482e6f5c2c2bf4ed24638886cd9dbe8446e09a2a6f73fa. Backup41c3d932c2d364da30841429267e2ebf3bf4ae79e416eaf54dda8603cb3def7d restaurado e conferido113tabelas,111protegidas;sem mudar contas/grants,stock,compromisso ou SGF. HTTP confirmou seis notas neutras,ausência das tarefas já cumpridas e requisição ainda approved/23,20MZN. Os estados pendentes/zero avaliações nas secções anteriores são históricos; não repetir estas operações. + +Integração adicional: medidas de melhoria ligadas a avaliações de qualidade aprovadas passam a alimentar a lista de tarefas de Compras. Execução apenas para o responsável já designado;conferência apenas para pessoa independente de todos os contribuidores. Devolução devolve a pendência ao responsável;encerramento retira-a da lista. Reutilizados prazo,referência,versão,estado,evidência e ligação directa #medida-ID da página nativa,sem criar tabela de tarefas,motor de decisão,novos campos obrigatórios ou novas permissões. A lista percorre todos os pendentes em lotes,sem corte nos100registos;continua a usar paginação existente da caixa de tarefas. A fonte aprovada conserva-se imutável. Este acompanhamento de qualidade é distinto das seis avaliações de propostas autorizadas acima. + +SupplierImprovementWorkflow.canAct partilhado por tarefas,página e comandos,com empresa,departamento,funções,conflitos e modo de manutenção revalidados. CanParticipate passou a recusar explicitamente manutenção e âmbito inadequado,também reutilizado nas respostas do fornecedor. Operador dos comandos continua actualizado dentro da transacção. Controller de avaliações passou a exigir utilizador interno autenticado,eliminando ambiguidade com conta de candidato. Sem alterações à sequência de aprovação,delegações ou regras de encerramento. + +Validação final:52testes/819verificações aprovados(SupplierImprovement,SupplierEvaluation,SupplierResponse,SupplierImprovementReport,WorkflowTaskInbox),incluindo5cenários de encaminhamento;estes5também passaram isolados em SQLite57verificações e PostgreSQL descartável57verificações,com remoção da base confirmada. Não somar execuções sobrepostas. PHPStan0erros,Pint e18hashes de execução aprovados. HTTP com Compras,Finanças e chefe confirmou consulta cifrada e caixa de tarefas;as2avaliações de qualidade já existentes foram apenas consultadas,sem novas medidas de piloto. Provas supplier-improvement-task-integration-pg/http.json e logs supplier-improvement-task-*. Sem alteração frontend,novo build,bateria geral ou revisão visual completa nesta etapa. + +Continuidade: desenvolver os restantes encaminhamentos nativos, incluindo contraditório e avaliações de qualidade em preparação/conferência,sem inferir decisões ou criar dados operacionais. Selecção de fornecedor/adjudicação/encomenda permanecem fora da autorização;isso não bloqueia desenvolvimento técnico independente. Preservar alterações concorrentes de contas a pagar e demais módulos. + +## 02/11/2026 — encaminhamento de avaliações de qualidade e contraditório concluído nesta etapa + +A pedido do proprietário para executar tarefas que aproximem a conclusão do produto, integrados dois percursos já existentes na caixa de tarefas de Compras: preparação/submissão e conferência das avaliações qualitativas de fornecedores; preparação/submissão e análise independente dos esclarecimentos/contraditórios. Estas avaliações qualitativas não são as seis avaliações das propostas do piloto. Não há criação automática de avaliação,declaração,nota,decisão ou prazo. + +SupplierEvaluationWorkflow.canAct e SupplierResponseWorkflow.canAct partilham a elegibilidade entre comandos,detalhe e tarefas: empresa,departamento,função actual,conflitos e independência de todos os contribuidores. Rascunhos/devoluções são encaminhados apenas aos contribuidores ainda elegíveis; isso não restringe a edição colaborativa já admitida no motor. Revisão chega às pessoas elegíveis independentes;devolução devolve a tarefa à preparação;aprovação/conclusão retira-a. Cada tarefa aponta à versão corrente da avaliação ou à âncora nativa #contraditorio-ID,sem copiar argumentos,evidência ou conclusão para a lista. Versão integra a identidade da tarefa,sem tabela de pendências paralela. Fila percorre todos os registos em lotes e conserva paginação25,pesquisa e pendências antigas,com105casos de cada percurso exercitados. Consultas não escrevem auditoria nem decisões;aprovação anterior conserva-se imutável. + +Acrescentada relação/âmbito autorizado SupplierResponse.evaluation/visibleTo,reutilizando a visibilidade da avaliação,sem migração. Preparação de avaliação bloqueia manutenção também no servidor;canPrepare alimenta canCreate e oculta o formulário nessas contas. Leitura continua disponível segundo os direitos existentes. Descrição das responsabilidades qualitativas acrescentada ao catálogo dos quatro papéis já admitidos pelo motor,sem concessão adicional. Texto da interface deixou de apresentar esclarecimentos/medidas já implementados como indisponíveis. Sem novos campos obrigatórios,sequência de aprovação ou perfis. + +Validação final:76testes/1276verificações aprovados na regressão SupplierEvaluation,SupplierResponse,SupplierImprovement,SupplierImprovementReport,WorkflowTaskInbox e InternalRequisitionCandidate. Onze cenários novos passaram também em PostgreSQL descartável,180verificações,com remoção da base confirmada. Incluem ciclo completo,devolução,preparação colaborativa,segregação,empresa/departamento,conflitos,manutenção/vigência,revogação de função com instância antiga,paginação e imutabilidade. As execuções intermédias61/977,74/1254 e PG9/158 são anteriores e sobrepostas,não somar. PHPStan0erros,Pint,tipos,lint,build e18hashes comuns aprovados;diff dos ficheiros desta etapa sem erros de espaços. + +HTTP em8005 confirmou a caixa de tarefas cifrada e a preparação disponível para os papéis operacionais existentes. Não existem actualmente tarefas qualitativas elegíveis nesses três perfis do piloto;por isso os percursos pendentes foram validados nas bases descartáveis,sem inventar novos registos no piloto. Confirmadas as seis avaliações de propostas anteriores e comparação pronta,sem seleccionar/adjudicar/encomendar. Provas supplier-quality-task-integration[-pg/-http].json e logs supplier-quality-task-*. Nenhuma migração,conta/grant,alteração SGF,contacto externo,bateria geral ou revisão visual completa executados nesta etapa. + +Pendências de continuidade: encaminhamento de qualidade/identificação/libertação de lotes e execução dos fornecimentos;paginação das consultas gerais ainda limitadas100/históricos200;parametrização das regras opcionais de reservas;instalação comercial do serviço periódico e validações finais de capacidade/recuperação/ecrãs/impressão. Não declarar a aplicação inteira concluída nem confundir funções existentes sem ensaio completo com módulos inexistentes. Preservar alterações concorrentes de contas a pagar e demais módulos. As avaliações operacionais autorizadas já aplicadas não devem ser repetidas. + +## 02/11/2026 — acompanhamento de fornecimentos e paginação da coordenação + +Executado novo incremento de conclusão funcional a pedido do proprietário: reservas de necessidades e entregas confirmadas passam à caixa de tarefas de Fornecimentos/reposição pelas competências existentes supply_reserve/supply_delivery. A reserva tratada aqui é de planeamento,distinta da reserva física warehouse_reservations das requisições internas. A tarefa encaminha acompanhamento,actualização/cancelamento ou associação de uma saída efectiva já existente;não bloqueia stock físico,não recebe automaticamente,não repete movimentos,não selecciona fornecedor nem emite ordem. Prazo reutiliza needed_on/expected_on sem inferir prioridades ou expiração. + +SupplyCoordination.canManage partilhado entre tarefas,página e comandos de coordenação: empresa,competência vigente,manutenção,última versão e estado actual. Tarefa de reserva só enquanto existe necessidade efectiva pendente;tarefa de entrega enquanto falta quantidade perante os recebimentos canónicos. Cancelamento encerra a pendência sem apagar a versão anterior. Estorno de saída torna a necessidade novamente pendente pelo cálculo nativo effectiveApplications,sem reabrir ou alterar o registo fulfilled histórico;uma nova saída autorizada pode satisfazer o saldo. Competência de acompanhamento não concede armazém,recepção ou autorização adicional. DTO de tarefa mínimo,liga ao foco/última versão existente e preserva o histórico,sem tabela paralela ou preenchimento repetido. + +As consultas de coordenação alternativas/reservas/entregas deixaram o corte100:paginação25 com total,anterior/seguinte e parâmetro records_page validado. Foco record_id mantém acesso directo à versão actual,independentemente da página;nenhum formulário novo no foco. Fonte de ordem/requisição seleccionada no foco é consultada explicitamente para preservar a edição de origens antigas,sem depender dos últimos100documentos gerais. As listas gerais de escolha de origem/saídas ainda têm limites100;isso não equivale a todos os selectores/históricos paginados. Sem alterar os demais ecrãs de SupplyPlanning nem prometer paginação global concluída. Responsabilidades supply_reserve/supply_delivery descritas no catálogo dos perfis com as permissões existentes;texto da interface clarifica planeamento vs reserva física. + +Validação final52testes/898verificações aprovados em SupplyCoordination,InventoryReversals,SupplyPlanning e WorkflowTaskInbox. Seis cenários de coordenação passaram também em PostgreSQL descartável107verificações,com base removida. Exercitados satisfação parcial/integral por saídas,recepção parcial/integral,cancelamento idempotente,estorno e nova satisfação,empresa,tipo de foco,manutenção/vigência,105pendências,paginação da caixa e consulta,foco antigo,consulta sem auditoria/movimento e preservação histórica. A primeira preparação dos105registos de teste omitia created_at ao replicar um modelo sem timestamps automáticos;corrigida apenas a fixture e reexecutadas as provas finais. A falha não corresponde a operação no piloto. PHPStan0erros,Pint,tipos,lint,build,diff e18hashes comuns aprovados. Não somar execuções intermédias ou os testes PG sobrepostos à regressão. + +HTTP8005:administraçao consulta as três páginas com paginação,sem preparação operacional;armazém/Compras/Finanças sem competências específicas receberam403,conforme configuração actual preservada. Nenhuma tarefa nova de coordenação pendente nesses perfis:cenários operacionais exercitados só nas bases descartáveis. Não criar grants,previsões,reservas,recepções ou movimentos no piloto para forçar demonstração. Provas coordination-task-integration[-pg/-http].json e logs coordination-task-*. Sem migração,conta nova,contacto externo,alteração SGF,decisão comercial,bateria geral ou revisão visual completa. + +Próximas lacunas:encaminhamento de identificação/retenção/libertação de lotes,paginação das restantes consultas gerais e selectores,instalação comercial resiliente do serviço periódico e validação final de capacidade/recuperação/ecrãs/impressão. Acompanhamento de acordos,execução contratual,importações e cobertura integral dos documentos base devem continuar confrontados com o código,sem declarar todos concluídos por esta integração. Preservar mudanças concorrentes de contas a pagar e demais módulos. As autorizações e avaliações de propostas anteriores continuam efectivas e não devem ser repetidas. + + +## Ciclo contabilistico comum sintetico - entrega autorizada + +Adaptador SGC e interface comum implementados: reconhecimento60, afectacao20/saldo40, contabilizacao, reversao/saldo60, par correctivo imutavel e fecho QA. Demo proprio http://127.0.0.1:8051/qa/ledger-demo com tres funcoes e base nova, sem efeitos de negocio iniciais. Ver docs/contabilidade-ciclo-sintetico.md e relatorio conjunto SGF. Provas nativas:23 controlos de contrato revertidos,61HTTP,10finais e28de preparacao; fontes/stock/orcamento externos preservados. Concessoes apenas QA, destino desactivado, sem alterar piloto8005 ou candidatas. Revisao visual, carga de implantacao, uso/fiscalidade reais e aceitacao separados; sem publicacao/checkpoint presumidos. + + +## Composicao autonoma do nucleo financeiro - QA + +Artefacto com25ficheiros de runtime, manifesto SHA256 fixado e verificacao antes do arranque, sem fallback quando alterado. FinanceBundle.configure e opt-in apenas em base/role nao elevada do cluster55439; sem root conserva composicao anterior e nao activa direitos/contabilidade. Demo nova8052, sem substituir8051/8005 ou pacote instalado. Provas:9contrato/escrita/replay,6integridade,4recusas no arranque e28preparacao do demo; PHPStan0erros nos2ficheiros novos. Ver SGF docs/COMMON_FINANCE_BUNDLE_DELIVERY.md. Sem dados/credenciais no artefacto, migracao no destino, publicacao ou checkpoint. + +## 02/11/2026 — encaminhamento da identificação e qualidade dos lotes + +Implementado acompanhamento na caixa de tarefas de Armazém sobre as configurações activas e o contexto canónico InventoryLots: identificação pendente para inventory_lot_prepare; conferência de lote retido elegível para inventory_lot_release independente dos contribuidores; conferência de retenção para inventory_lot_hold quando uma libertação anterior existe mas a validade actual é insuficiente. Não são atribuídas retenções a todos os lotes libertados saudáveis nem inventados prazos,prioridades,novos perfis ou decisões. O vencimento é lido pela data actual sem exigir movimento físico;criar/consultar tarefa não grava decisão nem aviso persistente. + +canIdentify/canDecideLot partilham elegibilidade entre comandos nativos,interface e tarefas: empresa,competência vigente,manutenção,versão de identificação,saldo repartido conferido,quantidade positiva e independência/validade para libertar. Mantidas verificações finais de versão e state_version no comando sob bloqueio da empresa. Identificação de lote sem saldo continua possível no motor quando já era permitida;apenas não gera tarefa. A identificação corrigida invalida a libertação anterior conforme o motor existente,sem alterar a decisão histórica. A dispensa de quarentena na recepção continua dependente da configuração aprovada existente;as novas tarefas não impõem nova aprovação nesse percurso. + +Novas ligações de foco lot_id+warehouse_id validam identidade inicial do lote,empresa,armazém,configuração activa e presença nesse local;abrem a identificação mais recente e a âncora estável lot-ARMAZEM-ID,mesmo com link antigo ou mais100pendências. Não misturar esse foco com record_id de configuração. Tipo numérico do identificador normalizado na resposta. Ligações antigas de configuração permanecem válidas;regra de exclusão corrigida para Rule::prohibitedIf suportada na versão instalada. Interface usa acções por lote da mesma autoridade;permite a retenção explícita de uma libertação já expirada,sem restaurar a disponibilidade pelo simples acesso. Descrições das tarefas de identificação,libertação e retenção acrescentadas aos perfis existentes,sem grants. + +Validação funcional abrange identificação→conferência→libertação,contribuidores de recepção,expiração sem movimento e retenção explícita,correcção posterior/versão antiga,empresa e par de foco/manutenção/vigência,saldo não reconciliado,105lotes em100partições iniciais+5de recepção nativa,paginação da caixa,política aprovada de recepção e lotes sem saldo. O saldo não reconciliado é injectado na leitura no teste:os triggers recusaram correctamente a tentativa inicial de preparar uma repartição impossível,não foram desactivados ou contornados. Expectativas antigas passam a reconhecer tarefas distintas de conferência da configuração e qualidade do saldo activo,sem alterar o estado ou a aprovação do processo. A evidência final desta execução é registada a seguir após a conclusão da regressão. + +No piloto8005 apenas consultas:administração continua só de leitura e os outros perfis sem competências específicas continuam403;nenhum lote/configuração/grant/decisão/movimento criado para demonstração. Prova HTTP lot-quality-task-integration-http.json. As validações operacionais permanecem nas bases descartáveis. Listagens gerais de configurações/histórico de lotes ainda conservam os limites anteriores100/200;foco e caixa paginada não equivalem a paginação integral desses históricos. Sem alteração SGF,selecção,adjudicação,encomenda ou contactos externos. + +### Evidência final — qualidade de lotes, 02/11/2026 + +Regressão final aprovada:99testes/1578verificações em InventoryLots,InventoryLotPolicy,InventoryReversals,InventoryDispositions,WarehouseFulfilment e WorkflowTaskInbox. Oito cenários específicos passaram em PostgreSQL descartável106verificações,com remoção da base confirmada. Execuções intermédias falhadas e o ensaio8/105 são anteriores/não somar. Foram corrigidos o tipo numérico do foco,a regra de validação das ligações e as expectativas dos testes de configuração,que agora reconhecem também a tarefa separada de libertação do saldo existente. Nenhuma falha de teste foi apresentada como decisão operacional aplicada. O cenário de leitura não reconciliada injecta a condição no teste e conserva os triggers físicos efectivos. + +Tipos,lint,build,Pint,PHPStan0erros,diff e18hashes comuns aprovados. O primeiro controlo de tipos detectou a âncora a usar warehouse_id fora de data;corrigido para a origem já usada na página e repetidos tipos/build. Provas lot-quality-task-integration[-pg/-http].json e logs lot-quality-task-*. Consulta HTTP com quatro perfis confirmou acesso de leitura administrativo,recusas operacionais sem competência e ausência de novas tarefas/lotes no piloto. As ligações e decisões com pendências foram testadas nas bases descartáveis. Não equivale a revisão visual completa,teste de carga,bateria geral de todos os módulos,aceitação real ou instalação SGF. + +Próxima continuidade técnica:paginar as consultas gerais de configurações/histórico de lotes e as restantes consultas/seletores ainda limitados;consolidar o arranque e recuperação do serviço periódico para instalação comercial;confrontar a cobertura de acordos/importações/execução contratual com os documentos base;validar capacidade,recuperação,relatórios,impressões e percursos por perfil. As tarefas de identificação/libertação/retenção aqui descritas deixaram de ser uma lacuna de encaminhamento. Não criar automaticamente concessões ou decisões no piloto para as tornar visíveis. Preservar aprovações vigentes,os percursos distintos de Compras/Pessoas e alterações concorrentes. O desenvolvimento e a integração interna mantêm autorização;novas decisões operacionais continuam a exigir o seu âmbito próprio. + +## 03/11/2026 — consultas de lotes paginadas, sem separar controlo activo da revisão + +Retirados os limites fixos100configurações/200registos históricos da página /stock/lotes. As configurações paginam25identidades de controlo por página;para cada identidade são carregadas a última versão e a configuração activa. Assim a revisão pendente e o controlo em vigor não se separam na fronteira da página. A contagem corresponde a controlos, podendo existir até duas versões visíveis por controlo. O foco de configuração/lote mantém acesso directo sem paginação de controlos. A lista de lotes de cada contexto continua integral nesse detalhe,não é um paginador individual por lote. + +Histórico paginado50registos,com total e navegação anterior/seguinte. controls_page/history_page independentes,validados e conservados nas ligações, juntamente com o foco. A segunda página do histórico abre o respectivo detalhe automaticamente. O histórico mantém o âmbito empresarial autorizado anterior;esta etapa não acrescenta pesquisa,filtros ou exportação. A leitura da página passou a transacção coerente e somente de leitura em PostgreSQL,sem bloqueio de escritor nem auditoria/movimento gerados em GET. + +Validação final51testes/821verificações aprovados em InventoryLots,InventoryLotPolicy e WorkflowTaskInbox. Os três cenários novos passaram também em PostgreSQL descartável98verificações,com base removida. Cobrem210controlos/registos de histórico,alcance das páginas antigas,preservação de parâmetros,foco directo,par activo+revisão na fronteira,empresa,páginas inválidas e leitura sem alteração de dados. A expectativa antiga100registos foi ajustada para25por página com105no total e acesso à quinta página. Tipos,lint,build,Pint,PHPStan0erros,diff e18hashes comuns aprovados. Provas lot-pagination[-pg/-http].json e logs lot-pagination-*. HTTP confirmou paginação vazia administrativa e recusas403 dos perfis sem competências;nenhum novo dado/grant no piloto. + +A regressão99/1578 e PG8/106 da etapa de qualidade imediatamente anterior continuam evidência própria dessa etapa,antes desta paginação;os51/821são a regressão final posterior da leitura alterada. Não somar provas sobrepostas nem declarar nova bateria integral dos módulos. Sem migração,aprovação de negócio,selecção,ordem,alteração SGF,contacto externo ou revisão visual completa. + +Continuar pelos restantes selectores/consultas gerais ainda limitados,preparação comercial do serviço periódico e recuperação,confronto da execução contratual/acordos/importações com os documentos base e validação final de capacidade,relatórios,impressões e percursos. As consultas gerais de configurações/histórico de lotes e o encaminhamento de qualidade deixaram de estar pendentes. Preservar aprovações e separação dos módulos;novas competências ou decisões do piloto não são concedidas por inferência. + +## 03/11/2026 — paginação de fornecimentos e preservação das origens escolhidas + +Retirado o corte fixo100 das consultas de acordos,políticas/calendário,análises de consumo,sugestões e processos de importação. RecordPage partilhado apresenta25identidades/últimas versões,contagem,navegação anterior/seguinte e records_page validado. Record_id conserva foco directo na última versão,sem depender da página,nem reintroduzir dados das origens. Componente de paginação partilhado nas três páginas de fornecimentos,sem novo motor de tarefas ou permissões. Consultas de coordenação,que já tinham paginação,mantêm o percurso próprio existente. A página de fornecimentos passou a leitura coerente,somente de leitura em PostgreSQL;projecções e contagens usam o mesmo estado sem bloquear operações de stock. + +As opções aprovadas de análise histórica/política passam a incluir as origens já usadas pelos registos visíveis,mesmo quando ficaram fora das100escolhas recentes. Só a última versão ainda aprovada é oferecida;uma suspensão remove a escolha e conserva a referência histórica no documento anterior. Nenhum GET altera snapshots,notas,pesos,aprovações,compras ou razão de stock. As100escolhas recentes continuam uma janela de apresentação,não uma promessa de selectores todos paginados. Acordos e catálogos ainda mantêm as capacidades/listas existentes. Não declarar toda a parametrização/escolha de origens concluída por esta etapa. + +Preparação de importações ganhou pesquisa opcional da ordem pelo número ou parte do número,aplicada antes da janela100,com parâmetros de pesquisa vinculados e comparação sem distinguir maiúsculas. Mantém no conjunto a ordem actualmente seleccionada/as ordens dos processos visíveis. Só quem já pode preparar recebe as opções;selected_order_id é validado por empresa e os leitores não recebem o selector. A pesquisa actualiza apenas orders/orderSearch via Inertia,preserva dados do formulário,página de registos e posição;Enter na pesquisa não submete o processo. Continua a usar a ordem canónica e o comando nativo de importação,que impede duplicação e exige registo antes da primeira recepção. + +Os testes incluem105registos por vista,quinta página/foco antigo,isolamento,páginas inválidas,vigência/manutenção,versões de importação,origens aprovadas fora da janela recente,retirada da opção quando suspensa,referências imutáveis,pesquisa/sem resultados/ordem seleccionada,entradas com apóstrofo,comprimento e empresa/leitura sem preparação. A primeira fixture de importação já tinha recepção completa e foi correctamente recusada pelo motor;substituída por uma fixture nova sem recepção,não se enfraqueceu essa regra. As expectativas antigas100registos passaram a25por página e contagens totais. Os resultados finais desta execução são registados após a conclusão das verificações. + +Piloto8005 apenas consultado nos perfis existentes,sem grants,cadastros,importação,aprovação,selecção,ordem ou movimento novo. RootAGENTS recebeu noutro trabalho autorização do ciclo contabilístico sintético em8051;esse âmbito paralelo é preservado e não alterado por esta etapa,assim como o SGF real. Prova HTTP supply-pagination-http.json cobre as cinco vistas e acessos actuais;os cenários com volume e decisões de teste só nas bases descartáveis. Sem revisão visual completa,teste de carga ou nova execução geral de todos os módulos. + +### Evidência final — consultas de fornecimentos e pesquisa de encomendas, 03/11/2026 + +Regressão final73testes/1378verificações aprovada em SupplyPagination,SupplyPlanning,SupplyHistory,SupplySuggestions,SupplyCoordination e SupplyTaskIntegration. Catorze cenários dirigidos passaram também em PostgreSQL descartável356verificações,com remoção da base confirmada. Incluem a suspensão posterior das origens:deixam de ser oferecidas para reutilização,mas os documentos preservam o identificador e os dados anteriores. As fases70/69aprovados,72/1329 e PG13/280ou307 são anteriores/sobrepostas,não somar nem declarar bateria geral. O único erro da preparação inicial de importação resultou de fixture já recebida;foi corrigida a fixture sem alterar a exigência nativa. + +Pint,PHPStan0erros,lint,tipos,build,diff e18hashes comuns aprovados. O artefacto compilado confirmou que a pesquisa conserva records_page. HTTP com administração,Compras e Finanças confirmou as cinco vistas/paginação conforme os acessos actuais e que a pesquisa não concede opções de preparação ao leitor. Provas supply-pagination[-pg/-http].json e logs supply-pagination-*. As escolhas de processos antigos e os dados em volume foram ensaiados nas bases descartáveis. Nenhuma decisão/grant/cadastro/stock/SGF alterado por estes comandos de desenvolvimento;sem nova revisão visual completa,teste de carga ou aceitação real. + +Próxima continuidade:demais selectores ainda limitados (por exemplo saídas efectivas para afectação de necessidades e novas escolhas de análises/políticas sem referência anterior),restantes consultas de ocorrências/estornos,preparação comercial do serviço periódico com recuperação e verificações de capacidade/relatórios/impressões. Confrontar acordos/importações/execução contratual com os documentos base antes de declarar a aplicação completa. A paginação principal de acordos,calendário,histórico,sugestões e importações e a pesquisa de ordens para importação deixaram de estar pendentes. Manter as decisões já autorizadas;não criar novas competências ou decisões no piloto por inferência. Preservar trabalhos paralelos,em particular o ciclo contabilístico autónomo e contas a pagar. + +## 03/11/2026 — consultas de ocorrências e estornos consolidadas + +Retirado o corte100processos em /stock/ocorrencias e /stock/estornos. InventoryCorrectionPage é uma projecção de leitura partilhada,sem novo motor ou permissões;os controladores mantêm a autoridade nativa e os comandos anteriores. Paginação25,contagem e filtros opcionais por referência/estado sobre as últimas versões. Pesquisa sem distinção de maiúsculas,parâmetros validados e conservados nas ligações. O foco directo ignora os filtros para apresentar a versão actual e o histórico integral desse processo,com as acções nativas de conferência/devolução/execução. Páginas em transacção coerente somente de leitura PostgreSQL,privadas e com história cifrada. Mantido o redireccionamento anterior para estornos quando só esse módulo está autorizado. + +Componentes de paginação e filtros reutilizados;filtros recolhidos quando não usados,sem novos campos de negócio obrigatórios. As escolhas de recepções e operações passam a conservar as origens já seleccionadas dos processos visíveis,mesmo fora das200recentes. Origens efectivamente estornadas são excluídas antes de aplicar a janela de escolhas e não são reintroduzidas por estarem num documento anterior. Referências,snapshots,aceitação original e documentos continuam imutáveis;as quantidades e a execução dependem das verificações nativas actuais. A janela200de novas escolhas permanece;isto não constitui pesquisa/paginação universal de todas as origens. + +Validação final55testes/1085verificações aprovados:InventoryCorrectionPagination,InventoryDispositions,InventoryReversals e WorkflowTaskInbox. Dez cenários específicos também aprovados em PostgreSQL descartável251verificações,com remoção da base confirmada. Cobertura:210processos por percurso,páginas antigas,filtros apenas sobre versão actual,foco antigo/histórico/acções independentes,empresa/tipo/páginas inválidas/manutenção/vigência,recepção e operação escolhidas fora das200recentes,estorno nativo e exclusão posterior das escolhas,consulta sem alterar stock,orçamento,auditoria ou decisões. As origens de volume foram criadas pelos comandos nativos em bases de teste;as correcções de teste usaram preparação,aprovação e execução independentes. Não somar provas sobrepostas nem declarar bateria geral. + +Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. Provas inventory-pagination[-pg/-http].json e logs inventory-pagination-*. HTTP8005 confirmou administração em leitura,armazém com execução anterior sem preparação/conferência nova,Compras/Finanças sem essas competências403,paginação/filtros vazios e validação dos parâmetros. Nenhuma concessão,ocorrência,estorno ou movimento novo no piloto,nem alteração SGF/8051,contacto externo ou decisão comercial. Sem revisão visual completa ou teste de carga;preservadas alterações paralelas de contabilidade e contas a pagar. + +Continuar pelos restantes selectores de origens ainda limitados,instalação comercial/recuperação do serviço periódico e validação de capacidade,relatórios,impressão e percursos por perfil. Consultas gerais de ocorrências/estornos deixam de ser uma lacuna de paginação;aprovações existentes não foram substituídas. Não reaplicar decisões ou autorizações anteriores nem declarar a aplicação inteira concluída por este incremento. +03/11/2026 — Consulta contabilística comum de ensaio concluída no âmbito de `docs/relatorios-contabilisticos-ensaio.md`: balancete e razão, sem novos efeitos ou direitos. Usar o núcleo canónico ou o artefacto QA novo de30 ficheiros; não substituir/activar o pacote congelado do destino ou8052. Preservar avaliações/autorização do piloto e trabalho paralelo. Revisão visual/carga sustentada/implantação e contabilização de stock/custos/salários continuam separadas. + + +## 03/11/2026 — autoridade das consultas partilhadas de correcções + +InventoryCorrectionPage passou a obter o utilizador actual da base,confirmar a empresa e reutilizar as competências nativas dos módulos também nas chamadas internas. Consulta de processos exige uma capacidade de leitura/preparação/conferência/execução do percurso;escolhas de recepções exigem preparação de ocorrências ou estornos;escolhas de operações exigem preparação de estornos. A permissão de consulta não passa a conceder preparação. Revogação/vigência/manutenção são reavaliadas mesmo com uma instância de utilizador anteriormente carregada. Não se alteraram perfis,sequências,alçadas ou comandos de aprovação/execução. + +Acrescentados quatro testes específicos de acesso para listas de origens e processos,com manutenção,expiração,leitor sem preparação e auditoria sem alterações. Regressão também cobre os cenários de paginação e correcções nativas anteriores. PHPStan sem erros e HTTP do piloto confirmado sem novos acessos ou decisões. Os resultados finais dos testes são registados após conclusão. Trabalho limitado à integração interna SGC;preservados SGF e o ciclo contabilístico paralelo. + +Validação final desta etapa:14testes/263verificações aprovados em InventoryCorrectionAccess e InventoryCorrectionPagination;PHPStan0erros,Pint,diff e HTTP do piloto aprovados. Não houve alteração frontend,nova execução PostgreSQL,bateria geral ou revisão visual nesta etapa. Provas nos logs inventory-access-tests.txt/inventory-access-phpstan.txt e inventory-pagination-http.json;resultados PostgreSQL anteriores não são nova execução desta alteração. + +## 03/11/2026 — pesquisa de saídas efectivas para necessidades planeadas + +Acrescentada pesquisa opcional por referência na acção de associar saída de uma reserva de planeamento. A pesquisa filtra antes da janela100 e vincula-se à identidade da necessidade,artigo e armazém;identidades de necessidade e saída validadas por empresa/tipo. O servidor usa a última versão e a elegibilidade nativa canManage(fulfil),recusando necessidades encerradas/canceladas ou sem competência. Leitores não recebem operações de preparação. Saídas estornadas ficam excluídas mesmo quando selected_issue_id tenta conservá-las;origens de outro artigo/local também não entram no conjunto. Sem campos de negócio novos ou nova concessão de tarefas. + +GET parcial actualiza operações,referência pesquisada e identidade de destino,preservando página,posicionamento,quantidade,justificação e selecção elegível. Enter no campo de pesquisa não submete a associação. Cada formulário mantém as próprias opções em memória,para uma pesquisa numa necessidade não apagar as escolhas de outra que esteja aberta. A saída escolhida fora dos resultados continua disponível apenas se pertence ao âmbito elegível. Continua a haver uma janela100 por pesquisa,sem alegar paginação universal do razão. Fonte e quantidade são novamente verificadas pelo comando nativo;associar não lança outra saída,não cria factura ou compra,não altera aprovações nem substitui a reserva física do armazém. + +Validação final54testes/971verificações aprovados em SupplyCoordination,InventoryReversals,SupplyPlanning e WorkflowTaskInbox. Três cenários dirigidos passaram também em PostgreSQL descartável85verificações,base removida. Incluem saída com100movimentos posteriores,pesquisa sem distinguir maiúsculas,selecção sem resultados,reutilização sem duplicar movimento,artigo/armazém,estado corrente,leitor sem preparação,empresa,comprimento do filtro e saída estornada que não reaparece pela selecção. A regressão conserva os percursos nativos de correcção,necessidades e tarefas. Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. + +HTTP8005 confirmou administração em leitura sem opções operacionais,recusas403 dos perfis sem acesso específico e validação do vínculo da pesquisa. Os casos positivos foram executados nas bases descartáveis;nenhum grant,reserva,associação,saída ou decisão novo no piloto. Provas reservation-source[-pg/-http].json e logs reservation-source-*. Sem migração,contactos externos,alteração SGF/8051,bateria geral ou revisão visual completa. Não somar execuções sobrepostas à regressão54. + +Continuidade:restantes selectores de novas origens históricas/políticas e de recepções/operações,instalação comercial e recuperação do serviço periódico,verificações de capacidade,relatórios/impressões e cobertura dos percursos documentados. A pesquisa de saídas para associação à necessidade deixa de ser uma lacuna. Preservar os motores e aprovações existentes e os trabalhos paralelos de contabilidade/contas a pagar;novas decisões operacionais do piloto permanecem sujeitas ao respectivo âmbito de autorização. + +## 03/11/2026 — pesquisa de análises aprovadas para políticas históricas + +A preparação de políticas com previsão histórica ganhou pesquisa opcional por período,usando o artigo,armazém e finalidade já escolhidos. O filtro aplica-se antes da janela100 de origens recentes,permitindo localizar uma análise antiga por datas. Fontes limitadas às últimas versões aprovadas e à empresa;identidades,âmbito,datas e ordem dos limites são validados no servidor. A origem seleccionada continua incluída fora do período pesquisado apenas quando ainda aprovada e do mesmo âmbito. Suspensão exclui-a sem alterar a referência histórica dos documentos existentes. + +GET parcial actualiza só as opções e a identidade do formulário,conservando dados,página e posição. Cada preparação/cópia mantém as próprias opções para uma pesquisa não apagar as escolhas de outra. Enter nos campos de pesquisa não submete a política. Não há novos campos de negócio obrigatórios,nem alteração da aprovação de análises/políticas. Qualidade,padrão,cobertura,assinatura do razão,actualidade e suspensão continuam a ser conferidos pelo motor nativo ao reutilizar a origem;encontrar uma opção não aprova a política nem altera a previsão de um documento já aprovado. + +Validação final53testes/1011verificações aprovados em SupplyHistory,SupplyPlanning,SupplySuggestions e SupplyPagination. Dois cenários específicos passaram também em PostgreSQL descartável62verificações,base removida. Cobertura:período fora das100escolhas recentes,selecção preservada,finalidade diferente,suspensão,empresa,âmbito incompleto,datas invertidas e leitor sem preparação,sem auditoria ou movimento gerado pela consulta. Tipos,lint,build,Pint,PHPStan0erros,diff e18hashes comuns aprovados. Provas history-source[-pg/-http].json e logs history-source-*. Não somar execuções sobrepostas nem declarar bateria geral. + +HTTP8005 confirmou pesquisa vinculada ao âmbito existente,preparação conforme o perfil e ausência de opções para quem só confere/consulta. Nenhuma análise,política,grant,decisão,stock,SGF ou ciclo contabilístico8051 alterado no piloto. Os casos positivos com períodos aprovados foram executados só em bases descartáveis. Sem revisão visual completa ou teste de carga. A janela100 de resultados por pesquisa permanece;isto não equivale a paginação universal de todos os selectores. + +Continuar pelos restantes selectores de políticas e origens de correcções,preparação comercial/recuperação do serviço periódico e validação de capacidade,relatórios,impressões e percursos. Preservar motores,aprovações,perfis e trabalhos paralelos;novas decisões operacionais não são concedidas por inferência. A pesquisa de análises históricas para políticas deixa de ser uma lacuna de acesso às origens antigas. + +## 03/11/2026 — pesquisa das políticas aprovadas para sugestões + +Preparação de sugestões ganhou pesquisa opcional por código/descrição do artigo e nome do armazém registados no snapshot aprovado. Filtragem sem distinguir maiúsculas,antes da janela100;seleccionados elegíveis mantidos fora dos resultados,sem reintroduzir política suspensa ou de outra empresa. selected_policy_key validado por empresa/tipo,consulta de origens apenas para supply_suggest_prepare. Leitores/conferentes não recebem escolhas de preparação. A pesquisa não selecciona fornecedor,aprova política ou gera compra. + +GET parcial actualiza só opções/texto/correlação do formulário,preservando página,posição,selecção e dados introduzidos. Cada preparação mantém as próprias opções para evitar interferência entre formulários;Enter na pesquisa não submete a sugestão. Origens aprovadas continuam sujeitas à basis nativa,à qualidade do histórico,à vigência,preço,tributação e revisão independente. Sem novos campos de negócio obrigatórios,sem alterar perfis,motores ou snapshots existentes. Janela100 de resultados por pesquisa permanece,não se declara paginação universal dos selectores. + +Validação final55testes/1077verificações aprovados em SupplySuggestions,SupplyHistory,SupplyPlanning e SupplyPagination. Dois cenários dirigidos em PostgreSQL descartável66verificações,base removida. Incluem política antiga fora das100recentes,caso sem resultados,selecção preservada,suspensão,empresa,comprimento/entrada com apóstrofo e conferente sem preparação;consultas sem auditoria,stock ou requisição novos e documento aprovado imutável. Fonte de volume construída com100políticas de artigos distintos pelos comandos nativos nas bases de teste. Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. + +HTTP8005 confirmou administração em leitura sem escolhas de preparação e recusas dos perfis sem acesso específico;cenários positivos só nas bases descartáveis. Provas policy-source[-pg/-http].json e logs policy-source-*. Sem grants,contas,sugestões,compras,movimentos ou decisões novos no piloto;SGF e demo contabilística8051 preservados. Sem nova bateria geral,revisão visual completa ou teste de carga. Não somar provas sobrepostas. + +Continuar pelas novas escolhas de recepções/operações nos percursos de correcção e restantes origens ainda limitadas,instalação comercial/recuperação do serviço periódico,capacidade,relatórios,impressões e cobertura dos documentos base. A pesquisa das políticas para sugestões deixa de ser uma lacuna de acesso a origens antigas. Preservar aprovações e autorizações anteriores e alterações paralelas,não declarar a aplicação inteira concluída por este incremento. + +## 03/11/2026 — pesquisa de novas origens de ocorrências e estornos + +Formulários de preparação passaram a procurar recepções pelo número e operações pela referência. Filtro sem distinguir maiúsculas aplicado antes da janela200;mantém fontes elegíveis já seleccionadas e fontes dos processos visíveis. Operações/linhas estornadas,tipos sem correcção física ou fontes de outra empresa não são reintroduzidos pela selecção. Direitos de preparação verificados pelo serviço nativo existente,com utilizador actual;leitores continuam sem opções. Parâmetros e identidades validados por empresa. + +Componente de pesquisa partilhado,opcional e recolhido,sem campos de negócio obrigatórios novos. GET parcial conserva dados,selecção elegível,página,filtros de processos e posição. Cada formulário mantém as suas opções para não interferir com outra preparação aberta. Enter não submete uma correcção. Seleccionar recepção continua a reutilizar artigo/armazém da origem;quantidades,saldo,unidades,lotes,independência e versão continuam a ser verificados nos comandos nativos. Nenhuma aprovação,saída ou correcção financeira executada pela pesquisa. Janela200por pesquisa continua;isto não é paginação universal das fontes. + +Validação final60testes/1158verificações aprovados em InventoryCorrectionPagination,InventoryCorrectionAccess,InventoryDispositions,InventoryReversals e WorkflowTaskInbox. Três cenários dirigidos em PostgreSQL descartável98verificações,base removida. Cobrem origens com200registos posteriores,pesquisa directa no serviço sem seleccionados,selecção preservada,estorno nativo e exclusão posterior,empresa,comprimento/entrada com apóstrofo e leitura sem preparação;sem mutações durante consulta,sem reescrever a recepção ou operação original. Pint,PHPStan0erros,tipos,lint,build,diff e18hashes comuns aprovados. Mantidos no runner os tempos dos modos anteriores de coordenação e qualidade de lotes. + +HTTP8005 confirmou os perfis existentes e ausência de novas escolhas operacionais para administração/armazém sem preparação;Compras/Finanças sem acesso específico403. Cenários positivos e decisões de correcção apenas em bases descartáveis. Provas correction-source[-pg/-http].json e logs correction-source-*. Sem grants,contas,correcções ou movimentos novos no piloto,sem alteração SGF/8051,contactos externos,bateria geral ou revisão visual completa. Não somar provas sobrepostas aos60testes. + +Continuar pelos restantes selectores de encomendas/requisições de fornecimentos,preparação comercial/recuperação do serviço periódico,capacidade,relatórios,impressões e cobertura integral dos documentos base. A pesquisa de novas recepções/operações para correcções deixa de ser uma lacuna de acesso às fontes antigas. Preservar autorizações existentes e trabalhos paralelos,não declarar a aplicação inteira concluída por este incremento. + +## 03/11/2026 — origens antigas no acompanhamento de fornecimentos + +A preparação de reservas planeadas e confirmações de entrega permite procurar, pelo número, requisições aprovadas e encomendas emitidas antes da janela das 100 mais recentes. Mantém as linhas elegíveis já seleccionadas e as origens dos registos visíveis. Uma selecção explícita não reintroduz requisições em rascunho, encomendas não emitidas ou dados de outra empresa. A linha continua a fornecer os dados canónicos, sem repetir cadastros ou alterar aprovações. + +Pesquisa opcional e recolhida, reutilizando o componente comum. Respostas parciais mantêm formulário, selecção, página e posição; cada preparação conserva as suas opções. Consultas não criam compras, recepções ou movimentos. Reservas planeadas e confirmações de entrega continuam distintas da reserva física e da recepção de stock. Mantém-se a janela de 100 fontes por pesquisa; não é paginação universal de selectores. + +Validação: 49 testes/1.047 verificações na regressão de coordenação, planeamento, paginação e caixa de tarefas; 3 testes/89 verificações em PostgreSQL descartável, removido no final. Cobertura inclui origem fora das 100 mais recentes, pesquisa sem distinguir maiúsculas, selecção preservada, utilização nativa da linha, exclusão de rascunhos, isolamento por empresa, leitura sem preparação e consultas sem mutações. Pint, PHPStan sem erros, tipos, lint e build aprovados. HTTP no piloto confirmou administração em leitura e recusas dos perfis sem competência específica, sem conceder direitos. + +Evidências: docs/proofs/supply-line-source.json, supply-line-source-pg.json e supply-line-source-http.json; logs supply-line-source-*. Nenhum comando de negócio no piloto, conta, grant, decisão de fornecedor ou alteração ao SGF. Sem nova bateria geral ou revisão visual completa. Não somar as provas sobrepostas. Permanecem a preparação comercial e recuperação do serviço periódico, capacidade, relatórios/impressões e conferência integral dos documentos base; este incremento não declara a aplicação concluída. + +## 03/11/2026 — acompanhamento nativo da verificação periódica + +sgc:replenishment-tick passou a gravar atomicamente o resultado técnico da própria execução em storage/framework/replenishment-command-status.json. Sucesso, trabalhos a recuperar e interrupção na descoberta/processamento ficam distinguíveis sem copiar pessoas, empresas, artigos, erros internos ou documentos. Falha ao escrever telemetria é reportada e avisada sem mascarar o resultado ou a excepção original. Limite inválido e composição não preparada não publicam sucesso. + +ReplenishmentQueue.status lê a observação nativa também fora do ambiente de avaliação. O ficheiro antigo do auxiliar permanece como fallback apenas no piloto e apenas se não existir observação nativa; não pode substituir uma falha nativa por sucesso. Dados danificados não certificam execução. Conservam-se a janela de actualidade, verificação da instalação e consulta das pendências duráveis; uma passagem bem sucedida não oculta trabalhos a recuperar. Isto observa passagens e não garante que um serviço continue activo depois delas. + +Validação final: 36 testes/406 verificações em ReplenishmentHeartbeat, WarehousePilotReadiness e AutomaticPurchases. Inclui sucesso, falha, excepção, limite inválido, ambiente comercial, prioridade sobre auxiliar, corrupção, obsolescência e recuperação existente. Ficheiros de teste isolados do piloto. Pint, PHPStan sem erros, diff e 18 hashes de execução aprovados. Prova docs/proofs/replenishment-heartbeat.json; logs replenishment-heartbeat-*. Não foi feita nova execução PostgreSQL, bateria geral ou revisão visual. Não há alteração de interface nesta etapa. + +Sem migração, comando de negócio manual no piloto, conta/grant, decisão de compra ou alteração SGF/8051. Não instalado serviço de arranque Windows: instalação comercial, recuperação após reinício, capacidade, relatórios/impressões e cobertura integral dos documentos continuam pendentes. Preservar o auxiliar existente e as aprovações; este acompanhamento não aprova nem encomenda e não constitui aceitação comercial da aplicação. + +## 03/11/2026 — exclusão local e recuperação do processo de reposição + +O comando nativo de reposição passou a adquirir um bloqueio de processo comum antes da descoberta/processamento, incluindo chamadas pelo auxiliar, agendador e operador. Usa posse do sistema operativo sem prazo que possa expirar a meio do lote. Uma segunda chamada não processa trabalhos, não substitui a observação concluída e devolve insucesso para não publicar falso sucesso através do auxiliar. Libertação em sucesso, excepção e terminação do processo; o ficheiro de controlo persistente não é sinal de bloqueio e não deve ser apagado durante execução. Não substitui transacções/recibos/idempotência existentes nem cria competências humanas. + +Regressão final 39 testes/425 verificações em ReplenishmentHeartbeat, WarehousePilotReadiness e AutomaticPurchases, mais 1 teste/5 verificações com processo PHP externo terminado pelo teste. Comprovadas recusa de concorrente, ausência de processamento/telemetria concorrente, libertação após erro e sucesso, destruição da instância e terminação externa sem apagar o ficheiro. Pint, PHPStan sem erros, diff e 18 hashes aprovados. Prova docs/proofs/replenishment-process-lock.json; logs replenishment-process-lock-* e replenishment-process-recovery-tests.txt. Sem nova execução PostgreSQL, bateria geral ou revisão visual; não somar estes resultados às regressões anteriores sobrepostas. + +Preparado docs/operacao-reposicao.md com configuração de tarefa recorrente, identidade técnica escolhida na instalação, directório inicial, diagnóstico, recuperação e conferência após reinício. Nenhuma tarefa Windows foi instalada nem credenciais copiadas. Bloqueio validado localmente, não como exclusão distribuída entre servidores. Sem comando manual de negócio no piloto, contas/grants, aprovações, decisões de fornecedor, migração ou alteração SGF/8051. Continuam pendentes instalação/ensaio comercial após reinício, capacidade, relatórios/impressões e cobertura integral dos documentos base. Não declarar aplicação concluída ou aceite comercialmente por este incremento. + +## 03/11/2026 — consulta filtrada do acompanhamento de fornecimentos + +Reservas planeadas, confirmações de entrega e alternativas técnicas passaram a permitir pesquisa opcional por código/nome do artigo, nome do local, referência da necessidade ou número da encomenda existente, com filtro de estado próprio de cada percurso. A consulta aplica filtros depois de seleccionar a versão mais recente de cada chave e antes de paginar25. Não ressuscita versões antigas activas de processos cancelados. Pesquisa sem distinguir maiúsculas, parâmetros limitados/validados e condições agrupadas dentro do âmbito da empresa. + +A interface mantém os filtros recolhidos quando vazios e não acrescenta campos de negócio obrigatórios. Nova filtragem reinicia a página; navegação conserva filtros. Pesquisas parciais de documentos/saídas continuam a preservar filtros, formulário e selecção. Ligação exacta de tarefa continua a apresentar a versão actual e histórico do processo, mesmo fora dos filtros. O estado é o registado; não deve ser confundido com pendência física calculada, especialmente nas reservas reabertas por estorno. Consultas não reservam/saem stock, recebem mercadoria ou alteram decisões. + +Validação: 54 testes/1158 verificações em SupplyCoordination,SupplyPlanning,SupplyPagination e WorkflowTaskInbox; 5 testes/111 verificações em PostgreSQL descartável, removido. Abrange origem antiga após26registos, exclusão de estado anterior, histórico preservado, filtros na página seguinte, foco exacto, artigo/local/encomenda, entradas com apóstrofo, empresa e ausência de mutações. Pint, PHPStan sem erros, lint, tipos, build e18hashes aprovados. HTTP no piloto conferiu leitura administrativa, validação e recusas dos perfis sem acesso específico, sem grants. Provas coordination-filters[-pg/-http].json e logs coordination-filters-*. + +Sem novas contas, decisões, comandos de negócio manuais no piloto ou alteração SGF/8051. Não houve revisão visual completa, ensaio de capacidade ou nova bateria geral; não somar provas sobrepostas. Continuam pendentes instalação/recuperação comercial após reinício, capacidade, relatórios/impressões e conferência integral dos documentos base. Este incremento melhora a consulta dos processos existentes; não declara novos relatórios ou conclusão integral da aplicação. + +## 03/11/2026 — exportação integrada do acompanhamento de fornecimentos + +Reservas planeadas, confirmações de entrega e alternativas técnicas passam a disponibilizar CSV a partir da responsabilidade da etapa. Exporta todos os resultados filtrados, sem limite da página25, ou apenas o processo em foco na sua versão actual. Reutiliza os mesmos direitos de leitura, filtros e consulta da página através de SupplyCoordinationRead, sem nova permissão ou segundo registo. Foco antigo resolve a chave e exporta a versão actual mesmo fora dos filtros, como a página; dados estrangeiros ou de outro tipo são recusados. + +CSV inclui momento da consulta, filtros, chave/versão/estado registado, artigo/local preservados, referência/encomenda, quantidade/unidade/data, quantidade por satisfazer calculada pelos serviços nativos, substituto/aplicação/validade e ligação exacta ao processo. Alternativas não recebem quantidades pendentes inventadas. Quantidade por satisfazer deve ser interpretada com o estado: não reactiva canceladas e não é reserva física ou compromisso de compra. Textos iniciados por fórmulas, incluindo espaços/controlos anteriores, são exportados como texto; delimitadores/aspas seguem CSV. Resposta privada sem cache, processamento por lotes100, transacção de leitura consistente em PostgreSQL. Não exporta automaticamente todo o histórico ou evidências confidenciais adicionais. + +Validação final58testes/1201verificações em SupplyCoordination,SupplyPlanning,SupplyPagination e WorkflowTaskInbox. Quatro testes/43verificações em PostgreSQL descartável, removido; cobrem todas as páginas, última versão, cancelamento, foco exacto, filtro, satisfação parcial, fórmula, empresa, manutenção/vigência e diferenças entre entrega/alternativa. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto confirmou exportações administrativas em leitura e recusas sem direitos específicos. Provas coordination-export[-pg/-http].json e logs coordination-export-*. Não somar regressões sobrepostas; não foi nova bateria geral, ensaio de capacidade ou revisão visual/impressão. + +Sem migração, conta/grant, comando de negócio manual no piloto ou decisão de fornecedor; SGF/8051 preservados. Permanecem a instalação comercial/ensaio após reinício do serviço periódico, capacidade, outros relatórios/impressões e conferência integral dos documentos base. Não apresentar este CSV de acompanhamento como autorização, documento de saída ou comprovação da conclusão integral da aplicação. + +## 03/11/2026 — prazos na caixa de tarefas integrada + +As caixas de tarefas de Compras/Armazém/Fornecimentos/Finanças e Pessoas permitem consultar todos os prazos, ultrapassados, com vencimento hoje, futuros ou sem prazo registado. Classificação de leitura em WorkflowTaskInboxController a partir exclusivamente de due_on da projecção nativa e da data do servidor, apresentada explicitamente como referência. Não copia tarefas para tabela, inventa prazos ou altera aprovações/atribuições. Documentos antigos continuam primeiro; o prazo não concede competência nem define prioridade automática. + +Contagens por prazo usam todas as tarefas autorizadas dentro do módulo e pesquisa, antes de filtrar prazo e paginar25. Cartões de módulos mantêm contagens globais anteriores. Formulário reinicia a página e paginação conserva filtros. Cada tarefa datada mostra a classificação, conservando ligação exacta à origem. A passagem do tempo actualiza a consulta sem novo movimento ou decisão; cancelamento nativo retira a tarefa sem reescrever histórico. Mantida projecção completa existente: este incremento não é ensaio ou optimização de capacidade. + +Regressão final87testes/2431verificações em WorkflowTaskInbox,SupplyCoordination e PeopleHiring. Dois cenários dirigidos em PostgreSQL descartável114verificações,base removida: limites de hoje/amanhã/ontem/sem data,28pendências antes da paginação,escopo módulo/pesquisa,contagens,parâmetros,passagem de dia e cancelamento sem mutações de consulta. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto conferiu cinco opções de prazo nos quatro perfis sintéticos existentes e recusa de opção inválida. Provas task-deadlines[-pg/-http].json e logs task-deadlines-*. + +Sem novos prazos configurados,contas/grants,decisões,comandos manuais de negócio no piloto,avisos externos ou alterações SGF/8051. Sem revisão visual completa,nova bateria geral ou ensaio de capacidade; não somar provas sobrepostas. Continuam pendentes instalação/ensaio comercial após reinício do serviço periódico,capacidade,outros relatórios/impressões e conferência integral dos documentos base. Esta consulta não aprova por silêncio nem constitui conclusão ou aceitação comercial integral do SGC. + +## 03/11/2026 — consulta e leitura contextual dos avisos internos + +Avisos de aprovação passam a ter pesquisa opcional pela referência da requisição, sem distinguir maiúsculas, sobre todos os avisos actualmente visíveis ao destinatário antes de paginar25. Mantidos filtros todos/por ler/intervenções/resultados e contador global de não lidos. Consulta usa transacção consistente somente de leitura em PostgreSQL, resposta privada sem cache e histórico Inertia encriptado. GET não cria nem lê avisos e não altera a elegibilidade nativa ou encaminhamento. + +Marcar como lido conserva vista,pesquisa e página em redireccionamento local construído apenas com parâmetros validados. Contexto inválido é recusado antes da marcação. A página é limitada à última válida quando a leitura retira a última linha do filtro; pesquisa nova reinicia a página. Mantidos comando nativo,controlo de empresa/destinatário,verificação actual de acesso e auditoria idempotente. Ler não decide nem submete requisição e não altera orçamento,stock ou etapa de aprovação. + +Regressão final24testes/757verificações em InternalRequisitionCandidate e WorkflowTaskInbox. Dois cenários dirigidos em PostgreSQL descartável142verificações,base removida. Cobertura de28avisos nativos,fonte antiga,pesquisa literal/entrada com apóstrofo,página/contexto preservados,contador global,consulta sem mutações,leitura repetida com uma auditoria e parâmetros inválidos sem leitura. Corrigidos no desenvolvimento a chamada encadeada do formulário e comparações do novo teste; só os resultados finais aprovados constituem esta prova. Pint,PHPStan0erros,lint,tipos finais,build,diff e18hashes aprovados. Artefacto compilado conferido sem chamada antiga encadeada. HTTP nos quatro perfis sintéticos existentes e quatro vistas,sem marcar avisos do piloto. Provas notice-consultation[-pg/-http].json e logs notice-consultation-*. + +Sem migração,contas/grants,leituras ou decisões de negócio novas no piloto;SGF/8051 preservados. Sem revisão visual completa,bateria geral ou ensaio de capacidade. Não somar provas sobrepostas. Permanecem instalação/ensaio comercial após reinício do serviço periódico,capacidade,outros relatórios/impressões e confronto integral com os documentos base. A pesquisa/readaptação dos avisos não transforma avisos históricos em tarefas actuais nem constitui conclusão integral do produto. + +## 03/11/2026 — confronto matriz/código/provas e pesquisa de acordos + +Matriz histórica de compatibilidade confrontada com código e provas posteriores em docs/matriz-sgc-reconciliada.md. Acrescentada referência no início de compatibilidade.md,conservando o histórico e separando piloto8005/SGFreal0.1.1/QA. Reposição,relatórios de Pessoas e correcções físicas não devem continuar tratados como globalmente ausentes; capacidades comuns/devolução desligadas não são activadas pelo facto de terem código/provas. Lacunas confirmadas incluem MFA não activo em Fortify e propostas ainda gravadas em MZN; restantes itens sem encerramento comprovado ficam explicitamente como tal. Separados desenvolvimento,instalação/configuração,validação e decisões humanas. Prova documental matrix-reconciliation.json fixa hashes das fontes consultadas,sem declarar nova execução dos testes antigos ou cobertura integral. + +Lacuna autorizada implementada: escolha de acordos no calendário deixava todos os acordos aprovados carregados integralmente e só filtrava artigo/local no navegador. Passou a pesquisa de referência/fornecedor no servidor,com âmbito artigo/armazém validado por empresa,100resultados recentes por pesquisa e preservação de fontes aprovadas utilizadas/seleccionadas. Suspensão e âmbito diferente não são contornados pela selecção. Mantido o conjunto de campos da resposta anterior,sem razões/auditoria adicionais. Validade,prazo conhecido,versão e aprovação da política continuam no motor nativo; a pesquisa não certifica elegibilidade contratual. Formulário conserva dados,página,foco e opções por preparação; pesquisa opcional recolhida,desactivada até escolher artigo/local,sem novos campos de negócio obrigatórios. + +Regressão final62testes/1381verificações em SupplyPagination,SupplyPlanning,SupplyCoordination e WorkflowTaskInbox;2testes/66verificações em PostgreSQL descartável,base removida. Inclui acordo fora das100fontes recentes,selecção conservada,utilização nativa da origem,exclusão após suspensão,empresa/âmbito,parâmetros/entrada com apóstrofo e leitor sem escolhas. Corrigida a data de criação das cópias sintéticas na preparação do teste; só resultados finais aprovados constituem esta entrega. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto confirmou os acessos existentes e âmbito obrigatório. Provas agreement-source[-pg/-http].json e logs agreement-source-*. + +Sem conta/grant,publicação de política,decisão comercial,comando manual de negócio no piloto ou alteração SGF/8051. Sem nova bateria geral,revisão visual ou ensaio de capacidade;não somar regressões sobrepostas. Próximas prioridades e limites estão na matriz reconciliada. Não declarar produto completo nem pedir nova autorização para decisões já autorizadas/aplicadas;seleccionar fornecedor/encomendar,activar capacidades ou instalar no destino continuam operações próprias. + +## 03/11/2026 — base nativa de autenticação de dois factores + +Confronto do código confirmou que a lacuna MFA ultrapassava configuração: User não usava TwoFactorAuthenticatable e o esquema não continha as três colunas mencionadas na documentação do modelo. Acrescentados trait/cast nativos e migração aditiva nullable para segredo cifrado,códigos de recuperação cifrados e confirmação. Não executada migração no piloto nem activada a feature. Nenhuma conta foi inscrita ou obrigatoriedade criada; roles/aprovações e política de acesso mantidas. + +Ecrãs Inertia portugueses para desafio por aplicação autenticadora/código de recuperação e confirmação de palavra-passe,usando exclusivamente endpoints/acções do Fortify instalado. Histórico encriptado,resposta privada sem cache e nenhuma chave/código de recuperação em props. Campos do formulário limpos após erro/sucesso. Limitador nativo de5tentativas/minuto por identidade em desafio/IP,ligado à configuração Fortify sem activar MFA. O formulário de gestão de adesão/confirmação/recuperação/desactivação ainda tem de ser concluído antes de propor activação; isto é base técnica,não entrega comercial integral de MFA. + +Regressão final25casos:23aprovados,2ignorados existentes por feature MFA desligada,426verificações. Os6cenários novos de base foram todos executados;6testes/60verificações em PostgreSQL descartável,base removida. Verificados segredo cifrado/omitido da serialização,feature desligada,conta confirmada em desafio,TOTP real do motor instalado,recuperação e recusa de reutilização sequencial,códigos inválidos/429,adesão não confirmada sem bloqueio de login e confirmação nativa da palavra-passe. Testes registam as rotas do próprio pacote apenas na aplicação descartável; corrigidas preparação do esquema e actualização dos nomes de rotas no teste antes dos resultados finais. Não é ensaio de recuperação concorrente,política obrigatória ou cobertura MFA completa de todas as rotas/composições. + +Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP nos perfis sintéticos de administração/armazém conferiu confirmação privada e desafio404 com MFA desligado,sem comandos de adesão ou mudança de palavra-passe. Provas mfa-foundation[-pg/-http].json e logs mfa-foundation-*. Sem SGF/8051,contas/grants,migração no piloto,decisões de compra,revisão visual completa,bateria geral ou aceitação comercial. A matriz reconciliada anterior conserva o seu instante/provas; agora há esta base técnica adicional,mas a pendência MFA não deve ser marcada como totalmente encerrada. + +Próxima continuidade:concluir interface de adesão protegida por palavra-passe,confirmação explícita do autenticador,consulta/renovação de recuperação e desactivação nativas,testar percursos e só então preparar eventual activação com migração e recuperação verificáveis. Não transformar autorização genérica de desenvolvimento em inscrição/obrigatoriedade das contas existentes. + +## 03/11/2026 — gestão voluntária MFA integrada na segurança da conta + +Concluída interface de adesão/retoma por chave manual,confirmação TOTP,consulta/ocultação/renovação de recuperação e cancelamento/desactivação explícita. Usa endpoints/acções nativos Fortify e o titular autenticado,sem campos de identidade paralelos. Segurança da conta exige confirmação recente de palavra-passe apenas quando MFA está deliberadamente activo. Preparação não confirmada não activa login;GET da página não modifica a preparação. Segredos não entram em props/estado recordado;respostas MFA privadas sem cache e códigos excluídos dos dados conservados após erro. Envio usa cabeçalho X-XSRF-TOKEN do cookie cifrado. Quando expira a confirmação,nova operação é recusada e a interface encaminha para reabrir segurança e confirmar palavra-passe. + +SGC_TWO_FACTOR_ENABLED documentado false em .env.example,configuração condicional com confirm/confirmPassword obrigatórios no mecanismo opcional. Não alterado .env,piloto ou SGF,nenhuma migração aplicada,conta inscrita,senha trocada ou grant. Obrigatoriedade por perfil e recuperação administrativa não foram introduzidas. Operação e limites em docs/mfa-adesao.md,com actualização da matriz reconciliada;não confundir código pronto com activação. + +Regressão final29casos:28aprovados,1ignorado existente,505verificações em gestão/segurança/autenticação/baseMFA/caixa de tarefas. O cenário de segurança com feature activa deixou de ser ignorado e é testado explicitamente. PostgreSQL descartável4testes/68verificações,base removida:adesão,propriedades sem chaves,códigos renovados distintos,desactivação recusada sem senha recente,isolamento do titular,código inválido sem activação/oldinput e CSRF nativo efectivamente activo recusando419 sem token e aceitando o cookie cifrado no cabeçalho. Corrigidas inferência do titular web e asserção da bolsa de erros nativa antes dos resultados finais. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP somente de leitura confirmou página de segurança sem opções MFA e endpoints inexistentes no piloto desligado. Provas mfa-management[-pg/-http].json e logs mfa-management-*. + +Sem revisão visual completa,ensaio de recuperação concorrente,bateria geral ou aceitação comercial;não somar provas sobrepostas à baseMFA anterior. Preparar operação concreta e salvaguarda/recuperação verificável antes de eventual pedido de activação;autorização de desenvolvimento não inscreve nem impõe MFA às contas existentes. Restantes lacunas da matriz e aprovações comerciais continuam separadas. + +## 03/11/2026 — exportação por processo dos metadados de auditoria + +Entregue CSV no painel Histórico da requisição,com metadados já visíveis:identidade,data,acção,utilizador e modo de execução. Página e exportação partilham RequisitionAuditTrail,sem segunda lista de eventos nem exportação do payload completo before/after. Consulta preserva empresa e Requisition.visibleTo,com actor web actual,manutenção/vigência verificadas antes de preparar e ao consumir a resposta. Não cria perfil de auditor global ou concede competências. Modo periódico conserva a atribuição técnica existente,sem inventar aprovação interactiva. + +Exporta todos os eventos disponíveis por identidade em lotes100,resposta privada sem cache e transacção PostgreSQL consistente/somente de leitura. CSV com BOM,tratamento de aspas/delimitadores e textos de fórmula neutralizados. GET e exportação não geram auditoria/recibo de leitura nem alteram documentos,etapas,orçamento ou stock. Fonte original e eventos imutáveis preservados. Âmbito e pendências em docs/auditoria-metadados-requisicao.md e matriz reconciliada:exportação geral,retenção,certificação,protecção SQL administrativa,volume e histórico paginado no ecrã continuam fora deste incremento. + +Regressão final35testes/1016verificações em InternalRequisitionCandidate,ProcurementReviewReport e WorkflowTaskInbox. PostgreSQL descartável2testes/139verificações,base removida. Testados105eventos adicionais ao evento nativo,sem corte na janela100,paridade com ecrã,ordem,exclusão de marcadores privados before/after,texto de fórmula,isolamento de requisitante/departamento/empresa,manutenção/vigência e comparação de registos antes/depois sem mutação. Corrigida referência ao identificador já desestruturado no componente antes da validação final de tipos/build. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP somente de leitura comparou detalhe/CSV da requisição sintética existente2 em quatro perfis e conferiu cabeçalhos/âmbito do conteúdo. Provas requisition-audit[-pg/-http].json e logs requisition-audit-*. + +Sem migração,contas/grants,decisões ou comandos manuais de negócio no piloto;SGF/8051 preservados. MFA e respectiva migração continuam desligados/não aplicados como antes. Sem nova bateria geral,revisão visual/folha de cálculo ou ensaio de capacidade;não somar regressões sobrepostas. Não declarar auditoria geral ou aplicação integralmente concluídas por esta exportação de metadados. + +## 03/11/2026 — histórico de requisições paginado e leitura coerente + +Histórico do detalhe da requisição passou a paginar25eventos por audit_page,validado1..1000000,conservando todos os registos e a ordem dos mais antigos primeiro. Metadados de paginação separados da lista,sem duplicar o payload. Ligações Inertia pedem apenas audit/auditPagination,preservam estado e posição e incluem âncora do histórico;preparações/decisões abertas não são reiniciadas apenas por mudar a página. A navegação parcial não decide processos;os comandos continuam a revalidar competências,origens e versões no motor nativo. + +Detalhe relê a requisição autorizada dentro de transacção consistente/somente de leitura em PostgreSQL,em vez de reutilizar apenas a fotografia do route binding obtida antes da transacção. Histórico Inertia encriptado. CSV existente continua integral e usa a projecção comum,sem herdar limite/página do ecrã. Sem novas competências,tabelas ou registos históricos. + +Regressão final35testes/1044verificações em InternalRequisitionCandidate,ProcurementReviewReport e WorkflowTaskInbox. PostgreSQL descartável2testes/167verificações,base removida:105eventos sintéticos adicionais à criação nativa,primeira/segunda/última página de6linhas,contagem integral,parâmetros inválidos,CSV completo,conteúdos privados excluídos,âmbito e comparação de registos sem mutações. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP somente de leitura no piloto confirmou encryptHistory,resposta parcial sem a requisição completa,lista até25,contador,recusa de página inválida,CSV e perfis sem acesso. Provas audit-pagination[-pg/-http].json e logs audit-pagination-*. Provas anteriores de metadados preservadas,sem as reescrever ou somar números sobrepostos. + +Sem migração,decisões,contas/grants,comandos manuais de negócio ou movimentos no piloto;SGF/8051 e activação MFA preservados. Matriz e documento de auditoria actualizados:paginação do histórico deixa de estar pendente,mas capacidade,visual/folha de cálculo,retenção,certificação e auditoria geral continuam sem encerramento por este incremento. Não houve bateria geral ou revisão visual completa e não foi declarada conclusão integral do SGC. + +## 03/11/2026 — indicador de necessidades por aprovar ligado ao relatório e CSV + +Relatórios de compras passaram a permitir attention=unapproved_needed,aberto directamente do indicador operacional ou pelo filtro opcional:estado draft/pending e data de necessidade anterior ao dia da consulta. Intersecta pesquisa,estado,departamento e período de criação existentes. Hoje/futuro e pedidos aprovados/rejeitados/cancelados não contam como necessidades por aprovar ultrapassadas. Data canónica apresentada na linha e acrescentada no fim do CSV,conservando as dez colunas anteriores. Não é prazo atribuído ao aprovador nem atraso comprovado do fornecedor e não cria tarefa/decisão por silêncio. + +Consulta/indicadores capturam o mesmo instante na leitura consistente;lista25,contagens sobre todos os resultados e CSV integral. Ao consumir a exportação,revalidados titular web,empresa,manutenção e funções actuais,construindo novamente a consulta dentro da transacção própria de leitura. Projecção de ordens recebe esse mesmo titular explícito em vez de depender de request global. Mantidos dinheiro exacto,conteúdo privado,CSV protegido e competências nativas. Revogação após preparar a resposta é recusada antes de emitir linhas. + +Regressão final29testes/726verificações em ProcurementReports,ProcurementReviewReport e WorkflowTaskInbox;PostgreSQL descartável2testes/71verificações,base removida. Testados27pedidos,duas páginas,CSV completo,hoje/futuro/estados fechados,parâmetros,passagem de dia,departamento e revogação no consumo do stream,sem mutações de consulta. Testes PostgreSQL confirmam fixtures apenas na base com nome guardado compras_receipts_test_<12hex> para exercitar a transacção própria;limpam schema temporário/funções entre casos,sem relaxar a salvaguarda da aplicação. Corrigida asserção da excepção encapsulada nativa do stream antes dos resultados finais. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto comparou contagem do indicador com lista/CSV nos perfis existentes. Provas needed-attention[-pg/-http].json e logs needed-attention-*. + +Sem migração,contas/grants,comandos manuais de negócio,novas datas de aprovação,decisões ou movimentos no piloto;SGF/8051 e activação MFA preservados. Matriz e docs/relatorios-necessidades-por-aprovar.md actualizados. Sem bateria geral,revisão visual,impressão ou ensaio de capacidade;não somar provas sobrepostas nem declarar relatórios/aplicação integralmente concluídos. + +## 03/11/2026 — contagens de aprovadas ligadas à consulta e exportação + +Relatórios passaram a abrir indicadores de aprovadas sem propostas registadas e aprovadas sem ordem visível,por attention=approved_without_quotes/approved_without_order. Campo Acompanhamento reúne as duas opções e a necessidade ultrapassada por aprovar,sem novos dados obrigatórios. Indicadores conservam filtros e regressam à primeira página. Consulta usa predicados SQL antes da paginação25 e CSV integral. Registo de proposta e ordem visível passam a consultas auxiliares comuns usadas tanto nos predicados como nas projecções de contagem/ordem,sem segunda regra de elegibilidade ou cópia de documentos. + +Mantidos acesso por empresa/departamento,actor explícito e controlo actual de competências/stream. Propostas expiradas continuam registadas para esta contagem;avaliação,elegibilidade,selecção e emissão permanecem nos motores/aprovações existentes. Os dois conjuntos sobrepõem-se e ausência de ordem visível não autoriza encomendar. Não foram criadas tarefas ou rascunhos pela leitura. + +Regressão final31testes/840verificações em ProcurementReports,ProcurementReviewReport e WorkflowTaskInbox. PostgreSQL descartável2testes/114verificações,base removida. Testados27aprovados sem documentos,proposta expirada registada sem ordem,pendente excluído,duas páginas,CSV completo,intersecção de estados,empresa/departamento e ausência de mutações nas fontes/orçamento/stock/auditoria. Corrigida apenas a obtenção da proposta da fixture no novo teste antes dos resultados finais. Mantido o isolamento PostgreSQL com guardas e limpeza do esquema temporário. Pint,PHPStan0erros,lint,tipos,build,diff e18hashes aprovados. HTTP no piloto comparou cada indicador com a lista/CSV para os perfis existentes,sem decisões. Provas approved-attention[-pg/-http].json e logs approved-attention-*. + +Matriz e docs/relatorios-aprovadas-sem-documentos.md actualizados. Sem migração,contas/grants,aprovações,escolha de fornecedor,emissão,movimentos ou comandos manuais de negócio no piloto;SGF/8051 e activação MFA preservados. Sem bateria geral,revisão visual,impressão ou capacidade;não somar provas sobrepostas nem declarar aplicação ou todos os relatórios completos. + +## 03/11/2026 — verificação técnica de saúde da reposição sem executar compras + +Entregue sgc:replenishment-health,com saída humana/--json e códigos0/1,sem descobrir origens,drenar fila,criar compras,aprovar ou actualizar heartbeat. Reutiliza observação e agregação de trabalhos de ReplenishmentQueue;consulta de consola sem identidade interactiva/sessão HTTP e transacção de leitura consistente em PostgreSQL quando própria. Resultado inclui somente instantes,motivos e contagens técnicas,não empresas/pessoas/artigos/erros privados. Falha de consulta produz indisponibilidade genérica e registo técnico. + +Separação explícita entre agregação global técnica e consulta por empresa,conservando inclusive o âmbito vazio de utilizador sem empresa. Nova passagem nativa associa o resultado à instalação/composição/base por hash,sem guardar nomes,caminhos ou credenciais. Associação diferente é rejeitada;legado sem associação mantém compatibilidade de leitura na interface mas não permite sucesso no novo comando. A verificação de saúde não reescreve/actualiza a observação para simular execução. Resultado técnico não é certificação de configuração funcional,processo activo futuro,assinatura do servidor ou aceitação comercial. + +Regressão final46testes/473verificações em ReplenishmentHealth,ReplenishmentHeartbeat,ReplenishmentProcessRecovery,WarehousePilotReadiness e AutomaticPurchases. PostgreSQL descartável6testes/43verificações,base removida. Cobrem leitura sem mutações de tabelas/telemetria,JSON/códigos de saída,passagem ausente/obsoleta/corrompida/falhada,pêndencias/falhas,duas empresas/sem empresa,composição diferente,recusa de identidade interactiva,excepção sem exposição e associação inválida/legada/correcta. Pint,PHPStan0erros,diff e18hashes aprovados. Sem alteração de frontend e sem alegar nova compilação ou revisão visual. HTTP só de leitura conferiu diagnóstico administrativo existente e recusa do perfil sem competência;comando de saúde executado apenas em bases descartáveis. Provas replenishment-health[-pg/-http].json e logs replenishment-health-*;provas históricas de heartbeat/processo preservadas,sem somar regressões sobrepostas. + +Matriz e docs/operacao-reposicao.md actualizados. Sem migração,contas/grants,decisões,comandos manuais de negócio no piloto,monitor recorrente ou instalação Windows;SGF/8051 e activação MFA preservados. Operação comercial após reinício,capacidade e restantes pendências da matriz continuam separadas. Não transformar este comando de consulta em prova de instalação,monitor activado ou aplicação concluída. + +## 03/11/2026 — entrega por bloco R1: recepção documental + +Adoptado o plano docs/plano-entregas-sgc.md: construir blocos funcionais e validar em conjunto no fecho, mantendo verificações críticas dirigidas durante a implementação. R1 acrescenta evidências privadas à recepção confirmada, reutilizando origem e responsabilidades nativas, com repetição segura, integridade na descarga, auditoria e navegação para o processo. Não repete stock, aprovação ou recepção física. + +Validação final: ReceiptDocumentBlockTest + ReceiptStockTest, 36 testes/271 verificações; PostgreSQL descartável 5/78, base removida. PHPStan sem erros, TypeScript, compilação de produção, Pint, diff e 18 hashes de execução conferidos. Prova docs/proofs/receipt-block.json e limites em docs/recepcao-documental.md. Sem revisão visual completa, carga ou instalação no piloto. Migração documental 2026_11_03_000002 pendente de autorização específica; não aplicar incidentalmente MFA. Sem novas contas, permissões ou decisões de negócio. R1 não declara todo o módulo Recepção concluído. diff --git a/docs/plano-entregas-sgc.md b/docs/plano-entregas-sgc.md new file mode 100644 index 0000000..55e3822 --- /dev/null +++ b/docs/plano-entregas-sgc.md @@ -0,0 +1,22 @@ +# Entregas por blocos funcionais do SGC + +03/11/2026 — orientação acordada com o proprietário. Substitui a sequência de pequenos incrementos de consulta. A matriz reconciliada continua a fonte de cobertura e provas; este plano organiza a execução, sem declarar módulos completos por terem menus. + +| Módulo | Base existente | Bloco a concluir | Critério de fecho | +| --- | --- | --- | --- | +| Recepção | Ordem emitida, recepção/qualidade, stock e histórico nativos | **R1 implementado e validado em bases descartáveis; instalação no piloto pendente** — evidências privadas ligadas à recepção, ordem e requisição | 36 testes/271 verificações de recepção e stock; PostgreSQL 5/78. Prova receipt-block.json; restantes funções de Recepção mantêm âmbito próprio | +| Compras | Requisição, aprovações, propostas, avaliação, adjudicação e ordens | Multimoeda e revisão comercial de propostas; cancelamento de ordens conforme âmbito a validar | Regras e efeitos financeiros definidos, origem/versionamento preservados e percurso fechado; não inventar câmbios ou novas alçadas | +| Armazém e reposição | Requisições internas, reserva, entrega, mínimos, recuperação e qualidade | Conversões gerais e fecho operacional das devoluções preparadas | Unidade/origem inequívocas e escritor único; activação/configuração separadas | +| Financeiro | Núcleo comum, orçamento, facturas, pagamentos e entregas sintéticas | Consolidar cobertura efectiva da composição e dos percursos financeiros pendentes | Reconciliar funcionalidades activas/QA, sem duplicar motores ou instalar pacotes por inferência; preservar trabalho paralelo | +| Pessoas | Cadastro, candidaturas, workflow e relatórios | Conferência de cobertura do percurso completo e passagem contratual | Dados reutilizados entre etapas e responsabilidades próprias, sem misturar permissões de Compras | +| Segurança | Adesão MFA voluntária implementada e desligada no piloto | Preparação de activação/recuperação e política empresarial | Operação concreta, salvaguarda e autorização antes de migração/activação; não inscrever contas automaticamente | +| Auditoria e documentos | Histórico por processo, metadados/CSV e paginação | Cobertura documental e retenção/exportação gerais conforme requisitos | Regras explícitas de acesso e preservação; não confundir CSV de metadados com auditoria integral | +| Operação comercial | Recuperação, exclusão local, heartbeat e healthcheck | Instalação do serviço, reinício, backup/restauro, capacidade e revisão visual | Ensaios de implantação/recuperação e aceitação próprios, com autorização para operações no destino | + +## Execução de R1 + +Reutilizar a recepção confirmada como origem, sem pedir novamente fornecedor, ordem, artigo, quantidades ou armazém. O armazém acrescenta apenas tipo de evidência e ficheiro. As mesmas regras de consulta da recepção protegem os anexos; escrita respeita competência de recepção, independência do emissor e manutenção. Documentos anexados são preservados, sem substituir a confirmação física ou alterar a sua decisão de qualidade. + +Construir em conjunto armazenamento privado, metadados, repetição/reutilização, descarga, interface no detalhe e referências de navegação. Testes dirigidos de ficheiros, acesso, repetição e rollback acompanham a construção. No fecho, executar uma bateria integrada de recepção documental e recepção/stock afectados; actualizar uma prova e a matriz. Sem baterias gerais ou reverificação de todos os outros módulos entre componentes. + +Substituições de artigos, reabertura de qualidade e cancelamento/estorno financeiro têm regras próprias e não são introduzidos implicitamente por anexar um documento. O fecho de R1 não declara todo o módulo Recepção, instalação ou produto comercial completos. Manter os percursos já aprovados e as autorizações anteriores. diff --git a/docs/proofs/agreement-source-http.json b/docs/proofs/agreement-source-http.json new file mode 100644 index 0000000..9725446 --- /dev/null +++ b/docs/proofs/agreement-source-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-03T07:00:24.803Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200 + }, + { + "role": "procurement", + "status": 200 + }, + { + "role": "finance", + "status": 200 + }, + { + "role": "warehouse", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing pilot access and required source scope; positive old source selection tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/agreement-source-pg.json b/docs/proofs/agreement-source-pg.json new file mode 100644 index 0000000..67ef3ba --- /dev/null +++ b/docs/proofs/agreement-source-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T07:02:52+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":66,\"duration_ms\":17310}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/agreement-source.json b/docs/proofs/agreement-source.json new file mode 100644 index 0000000..dbab66c --- /dev/null +++ b/docs/proofs/agreement-source.json @@ -0,0 +1,26 @@ +{ + "checked_at": "2026-11-03T07:05:22.6192371Z", + "scope": "Approved agreement lookup scoped to canonical policy article and warehouse", + "result": "passed", + "regression": { + "assertions": 1381, + "tests": 62 + }, + "postgres": { + "disposable_database_removed": true, + "tests": 2, + "assertions": 66 + }, + "frontend": { + "types": "passed", + "lint": "passed", + "build": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/alternative-task-integration-http.json b/docs/proofs/alternative-task-integration-http.json new file mode 100644 index 0000000..37252ba --- /dev/null +++ b/docs/proofs/alternative-task-integration-http.json @@ -0,0 +1,27 @@ +{ + "checked_at": "2026-11-02T19:46:03.014Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "routes": [ + { + "role": "admin", + "alternatives_status": 200, + "alternative_tasks": 0 + }, + { + "role": "procurement", + "alternatives_status": 403, + "alternative_tasks": 0 + }, + { + "role": "finance", + "alternatives_status": 403, + "alternative_tasks": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Live synthetic scope has no technical alternatives or specific execution grants; technical review, independence and source changes tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/alternative-task-integration-pg.json b/docs/proofs/alternative-task-integration-pg.json new file mode 100644 index 0000000..5223b32 --- /dev/null +++ b/docs/proofs/alternative-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T19:41:49+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":3,\"passed\":3,\"assertions\":110,\"duration_ms\":21793}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/alternative-task-integration.json b/docs/proofs/alternative-task-integration.json new file mode 100644 index 0000000..a69099c --- /dev/null +++ b/docs/proofs/alternative-task-integration.json @@ -0,0 +1,40 @@ +{ + "checked_at": "2026-11-02T19:49:09.9689352Z", + "scope": "Native technical alternative review tasks and exact source focus", + "regression": { + "tool": "pest", + "result": "passed", + "tests": 55, + "passed": 55, + "assertions": 1012, + "duration_ms": 62709 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 3, + "passed": 3, + "assertions": 110, + "duration_ms": 21793 + }, + "http_proof": "alternative-task-integration-http.json", + "typescript": "passed", + "lint_build": "passed", + "phpstan": "passed", + "pint": "passed", + "runtime_hashes": 18, + "overlapping_suites_must_not_be_summed": true, + "visual_review": false, + "general_suite_rerun": false, + "new_accounts_or_grants": false, + "sgf_installation_changed": false, + "limitations": [ + "Current pilot has no technical alternatives or specific technical execution grants; pending decisions tested in disposable databases", + "Normal coordination overview still limited to 100; complete task traversal and exact focus preserve older pending records", + "Technical approval does not select a supplier, change stock, or automatically replace an article" + ], + "authorized_approval": "pilot-approval-activation.json", + "approval_http": "pilot-approval-activation-http.json", + "next_pending_proposal": "pilot-market-proposal.json", + "next_disposable_proof": "pilot-market-dry-run.json" +} diff --git a/docs/proofs/approved-attention-http.json b/docs/proofs/approved-attention-http.json new file mode 100644 index 0000000..17f1eb7 --- /dev/null +++ b/docs/proofs/approved-attention-http.json @@ -0,0 +1,52 @@ +{ + "checked_at": "2026-11-03T08:36:07.326Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "attention": "approved_without_quotes", + "status": 200 + }, + { + "role": "admin", + "attention": "approved_without_order", + "status": 200 + }, + { + "role": "procurement", + "attention": "approved_without_quotes", + "status": 200 + }, + { + "role": "procurement", + "attention": "approved_without_order", + "status": 200 + }, + { + "role": "finance", + "attention": "approved_without_quotes", + "status": 200 + }, + { + "role": "finance", + "attention": "approved_without_order", + "status": 200 + }, + { + "role": "warehouse", + "attention": "approved_without_quotes", + "status": 403 + }, + { + "role": "warehouse", + "attention": "approved_without_order", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing pilot facts and authorised CSV; populated overlap, registered expired proposals and pagination tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/approved-attention-pg.json b/docs/proofs/approved-attention-pg.json new file mode 100644 index 0000000..ba5e0b8 --- /dev/null +++ b/docs/proofs/approved-attention-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T08:36:36+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":114,\"duration_ms\":12860}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/approved-attention.json b/docs/proofs/approved-attention.json new file mode 100644 index 0000000..1a14e97 --- /dev/null +++ b/docs/proofs/approved-attention.json @@ -0,0 +1,27 @@ +{ + "checked_at": "2026-11-03T08:38:48.8327229Z", + "scope": "Approved requisition indicator drilldown sharing registered quote and visible order queries", + "result": "passed", + "regression": { + "tests": 31, + "passed": 31, + "assertions": 840 + }, + "postgres": { + "tests": 2, + "disposable_database_removed": true, + "assertions": 114 + }, + "frontend": { + "types": "passed", + "build": "passed", + "lint": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/audit-pagination-http.json b/docs/proofs/audit-pagination-http.json new file mode 100644 index 0000000..3b97812 --- /dev/null +++ b/docs/proofs/audit-pagination-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-03T08:09:13.412Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200 + }, + { + "role": "procurement", + "status": 200 + }, + { + "role": "finance", + "status": 200 + }, + { + "role": "warehouse", + "status": 404 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing synthetic requisition 2: scoped detail, partial audit pagination and full CSV access." +} \ No newline at end of file diff --git a/docs/proofs/audit-pagination-pg.json b/docs/proofs/audit-pagination-pg.json new file mode 100644 index 0000000..7d8a6ae --- /dev/null +++ b/docs/proofs/audit-pagination-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T08:08:12+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":167,\"duration_ms\":40123}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/audit-pagination.json b/docs/proofs/audit-pagination.json new file mode 100644 index 0000000..19ac6f3 --- /dev/null +++ b/docs/proofs/audit-pagination.json @@ -0,0 +1,27 @@ +{ + "checked_at": "2026-11-03T08:11:03.0678494Z", + "scope": "Paginated requisition history with partial navigation, consistent native detail and complete CSV", + "result": "passed", + "regression": { + "tests": 35, + "passed": 35, + "assertions": 1044 + }, + "postgres": { + "tests": 2, + "disposable_database_removed": true, + "assertions": 167 + }, + "frontend": { + "lint": "passed", + "build": "passed", + "types": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/coordination-export-http.json b/docs/proofs/coordination-export-http.json new file mode 100644 index 0000000..7478c22 --- /dev/null +++ b/docs/proofs/coordination-export-http.json @@ -0,0 +1,72 @@ +{ + "checked_at": "2026-11-03T06:07:03.345Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "view": "reservations", + "status": 200 + }, + { + "role": "admin", + "view": "deliveries", + "status": 200 + }, + { + "role": "admin", + "view": "alternatives", + "status": 200 + }, + { + "role": "warehouse", + "view": "reservations", + "status": 403 + }, + { + "role": "warehouse", + "view": "deliveries", + "status": 403 + }, + { + "role": "warehouse", + "view": "alternatives", + "status": 403 + }, + { + "role": "procurement", + "view": "reservations", + "status": 403 + }, + { + "role": "procurement", + "view": "deliveries", + "status": 403 + }, + { + "role": "procurement", + "view": "alternatives", + "status": 403 + }, + { + "role": "finance", + "view": "reservations", + "status": 403 + }, + { + "role": "finance", + "view": "deliveries", + "status": 403 + }, + { + "role": "finance", + "view": "alternatives", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing pilot export access; populated exports and native quantities tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/coordination-export-pg.json b/docs/proofs/coordination-export-pg.json new file mode 100644 index 0000000..88ec1db --- /dev/null +++ b/docs/proofs/coordination-export-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T06:06:31+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":4,\"passed\":4,\"assertions\":43,\"duration_ms\":6304}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/coordination-export.json b/docs/proofs/coordination-export.json new file mode 100644 index 0000000..92b8261 --- /dev/null +++ b/docs/proofs/coordination-export.json @@ -0,0 +1,26 @@ +{ + "checked_at": "2026-11-03T06:07:35.9899003Z", + "scope": "Filtered coordination export sharing canonical read authority and native pending quantities", + "result": "passed", + "regression": { + "assertions": 1201, + "tests": 58 + }, + "postgres": { + "disposable_database_removed": true, + "tests": 4, + "assertions": 43 + }, + "frontend": { + "types": "passed", + "build": "passed", + "lint": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/coordination-filters-http.json b/docs/proofs/coordination-filters-http.json new file mode 100644 index 0000000..613e438 --- /dev/null +++ b/docs/proofs/coordination-filters-http.json @@ -0,0 +1,72 @@ +{ + "checked_at": "2026-11-03T05:58:53.079Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "view": "reservations", + "status": 200 + }, + { + "role": "admin", + "view": "deliveries", + "status": 200 + }, + { + "role": "admin", + "view": "alternatives", + "status": 200 + }, + { + "role": "warehouse", + "view": "reservations", + "status": 403 + }, + { + "role": "warehouse", + "view": "deliveries", + "status": 403 + }, + { + "role": "warehouse", + "view": "alternatives", + "status": 403 + }, + { + "role": "procurement", + "view": "reservations", + "status": 403 + }, + { + "role": "procurement", + "view": "deliveries", + "status": 403 + }, + { + "role": "procurement", + "view": "alternatives", + "status": 403 + }, + { + "role": "finance", + "view": "reservations", + "status": 403 + }, + { + "role": "finance", + "view": "deliveries", + "status": 403 + }, + { + "role": "finance", + "view": "alternatives", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing pilot access and validation; positive record filtering verified in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/coordination-filters-pg.json b/docs/proofs/coordination-filters-pg.json new file mode 100644 index 0000000..86ac021 --- /dev/null +++ b/docs/proofs/coordination-filters-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T05:58:55+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":5,\"passed\":5,\"assertions\":111,\"duration_ms\":14987}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/coordination-filters.json b/docs/proofs/coordination-filters.json new file mode 100644 index 0000000..31eb7d9 --- /dev/null +++ b/docs/proofs/coordination-filters.json @@ -0,0 +1,25 @@ +{ + "checked_at": "2026-11-03T06:00:24.7705366Z", + "scope": "Latest coordination records filtered before pagination without changing authority or history", + "result": "passed", + "regression": { + "assertions": 1158, + "tests": 54 + }, + "postgres": { + "disposable_database_removed": true, + "assertions": 111, + "tests": 5 + }, + "frontend": { + "build": "passed", + "types": "passed", + "lint": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false +} diff --git a/docs/proofs/coordination-task-integration-http.json b/docs/proofs/coordination-task-integration-http.json new file mode 100644 index 0000000..c20facb --- /dev/null +++ b/docs/proofs/coordination-task-integration-http.json @@ -0,0 +1,96 @@ +{ + "checked_at": "2026-11-02T21:34:22.144Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "pages": [ + { + "view": "reservations", + "status": 200, + "can_prepare": false + }, + { + "view": "deliveries", + "status": 200, + "can_prepare": false + }, + { + "view": "alternatives", + "status": 200, + "can_prepare": false + } + ], + "first_page_followup_tasks": 0 + }, + { + "role": "warehouse", + "pages": [ + { + "view": "reservations", + "status": 403, + "can_prepare": null + }, + { + "view": "deliveries", + "status": 403, + "can_prepare": null + }, + { + "view": "alternatives", + "status": 403, + "can_prepare": null + } + ], + "first_page_followup_tasks": 0 + }, + { + "role": "procurement", + "pages": [ + { + "view": "reservations", + "status": 403, + "can_prepare": null + }, + { + "view": "deliveries", + "status": 403, + "can_prepare": null + }, + { + "view": "alternatives", + "status": 403, + "can_prepare": null + } + ], + "first_page_followup_tasks": 0 + }, + { + "role": "finance", + "pages": [ + { + "view": "reservations", + "status": 403, + "can_prepare": null + }, + { + "view": "deliveries", + "status": 403, + "can_prepare": null + }, + { + "view": "alternatives", + "status": 403, + "can_prepare": null + } + ], + "first_page_followup_tasks": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Read-only pilot access; partial/full receipts, allocations and corrections exercised in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/coordination-task-integration-pg.json b/docs/proofs/coordination-task-integration-pg.json new file mode 100644 index 0000000..4405b14 --- /dev/null +++ b/docs/proofs/coordination-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T21:33:32+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":6,\"passed\":6,\"assertions\":107,\"duration_ms\":22414}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/coordination-task-integration.json b/docs/proofs/coordination-task-integration.json new file mode 100644 index 0000000..1cb3f41 --- /dev/null +++ b/docs/proofs/coordination-task-integration.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-02T21:35:25.0515777Z", + "result": "passed", + "scope": "supply planning reservations and confirmed deliveries follow-up; coordination pagination", + "regression": { + "assertions": 898, + "tests": 52 + }, + "postgres_task_checks": { + "assertions": 107, + "tests": 6, + "disposable_database_removed": true + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "frontend": { + "build": "passed", + "types": "passed", + "lint": "passed" + }, + "accounts_or_grants_changed": false, + "pilot_business_commands": false, + "sgf_changed": false, + "visual_review": false, + "proof_files": [ + "coordination-task-integration-pg.json", + "coordination-task-integration-http.json" + ] +} diff --git a/docs/proofs/correction-source-http.json b/docs/proofs/correction-source-http.json new file mode 100644 index 0000000..e8dfa90 --- /dev/null +++ b/docs/proofs/correction-source-http.json @@ -0,0 +1,52 @@ +{ + "checked_at": "2026-11-03T05:26:47.356Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "path": "/stock/ocorrencias", + "status": 200 + }, + { + "role": "admin", + "path": "/stock/estornos", + "status": 200 + }, + { + "role": "warehouse", + "path": "/stock/ocorrencias", + "status": 200 + }, + { + "role": "warehouse", + "path": "/stock/estornos", + "status": 200 + }, + { + "role": "procurement", + "path": "/stock/ocorrencias", + "status": 403 + }, + { + "role": "procurement", + "path": "/stock/estornos", + "status": 403 + }, + { + "role": "finance", + "path": "/stock/ocorrencias", + "status": 403 + }, + { + "role": "finance", + "path": "/stock/estornos", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Existing pilot read rights; positive old origins and native reversals tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/correction-source-pg.json b/docs/proofs/correction-source-pg.json new file mode 100644 index 0000000..7c72646 --- /dev/null +++ b/docs/proofs/correction-source-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T05:26:45+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":3,\"passed\":3,\"assertions\":98,\"duration_ms\":92593}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/correction-source.json b/docs/proofs/correction-source.json new file mode 100644 index 0000000..d724983 --- /dev/null +++ b/docs/proofs/correction-source.json @@ -0,0 +1,25 @@ +{ + "scope": "receipt and operation lookup for native inventory corrections", + "checked_at": "2026-11-03T05:29:23.6793001Z", + "runtime_hashes": 18, + "frontend": { + "lint": "passed", + "types": "passed", + "build": "passed" + }, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "result": "passed", + "postgres_checks": { + "disposable_database_removed": true, + "assertions": 98, + "tests": 3 + }, + "pilot_business_commands": false, + "visual_review": false, + "phpstan_errors": 0, + "regression": { + "assertions": 1158, + "tests": 60 + } +} diff --git a/docs/proofs/disposition-task-integration-pg.json b/docs/proofs/disposition-task-integration-pg.json new file mode 100644 index 0000000..877b446 --- /dev/null +++ b/docs/proofs/disposition-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T20:15:04+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":5,\"passed\":5,\"assertions\":184,\"duration_ms\":27935}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/formal-task-integration.json b/docs/proofs/formal-task-integration.json new file mode 100644 index 0000000..57a9744 --- /dev/null +++ b/docs/proofs/formal-task-integration.json @@ -0,0 +1,39 @@ +{ + "verification": { + "checked_at": "2026-11-02T18:48:35.4905049Z", + "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": 156, + "passed": 156, + "assertions": 2860, + "duration_ms": 160273 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 11, + "passed": 11, + "assertions": 383, + "duration_ms": 35159 + }, + "http_proof": "formal-task-integration-http.json", + "scope": "Documentary own pending answers, formal admission and recommendation reviews through existing authorities", + "limitations": [ + "No invented mandatory analyst or approval rule", + "No automatic formal decision or persistent task table", + "Persistent QA had zero formal pending tasks; decisions tested in disposable databases" + ] +} diff --git a/docs/proofs/history-source-http.json b/docs/proofs/history-source-http.json new file mode 100644 index 0000000..6a3d9e8 --- /dev/null +++ b/docs/proofs/history-source-http.json @@ -0,0 +1,27 @@ +{ + "checked_at": "2026-11-02T23:14:55.737Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "can_prepare": false, + "source_choices": 0 + }, + { + "role": "procurement", + "can_prepare": true, + "source_choices": 0 + }, + { + "role": "finance", + "can_prepare": false, + "source_choices": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Existing pilot scope and lookup validation; approved historical periods and suspensions tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/history-source-pg.json b/docs/proofs/history-source-pg.json new file mode 100644 index 0000000..81e3b24 --- /dev/null +++ b/docs/proofs/history-source-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T23:12:49+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":62,\"duration_ms\":19930}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/history-source.json b/docs/proofs/history-source.json new file mode 100644 index 0000000..863426b --- /dev/null +++ b/docs/proofs/history-source.json @@ -0,0 +1,25 @@ +{ + "frontend": { + "types": "passed", + "lint": "passed", + "build": "passed" + }, + "sgf_changed": false, + "accounts_or_grants_changed": false, + "pilot_business_commands": false, + "phpstan_errors": 0, + "scope": "approved consumption history lookup for existing replenishment policy workflow", + "checked_at": "2026-11-02T23:16:10.1814994Z", + "visual_review": false, + "regression": { + "tests": 53, + "assertions": 1011 + }, + "runtime_hashes": 18, + "postgres_checks": { + "tests": 2, + "disposable_database_removed": true, + "assertions": 62 + }, + "result": "passed" +} diff --git a/docs/proofs/history-task-integration-http.json b/docs/proofs/history-task-integration-http.json new file mode 100644 index 0000000..b2f2839 --- /dev/null +++ b/docs/proofs/history-task-integration-http.json @@ -0,0 +1,34 @@ +{ + "checked_at": "2026-11-02T19:07:15.164Z", + "result": "passed", + "environment": "http://127.0.0.1:8005", + "routes": [ + { + "role": "admin", + "history_status": 200, + "history_tasks": 0 + }, + { + "role": "admin", + "pilot_installation": true, + "warehouse_scenario_ready": true, + "replenishment_scenario_ready": true, + "client_configuration_complete": false + }, + { + "role": "procurement", + "history_status": 403, + "history_tasks": 0 + }, + { + "role": "finance", + "history_status": 403, + "history_tasks": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Existing current scope has no historical analysis and no historical execution grants; pending historical reviews tested only in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/history-task-integration-pg.json b/docs/proofs/history-task-integration-pg.json new file mode 100644 index 0000000..46e5b52 --- /dev/null +++ b/docs/proofs/history-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T19:06:17+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":7,\"passed\":7,\"assertions\":193,\"duration_ms\":34551}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/history-task-integration.json b/docs/proofs/history-task-integration.json new file mode 100644 index 0000000..7f664df --- /dev/null +++ b/docs/proofs/history-task-integration.json @@ -0,0 +1,48 @@ +{ + "checked_at": "2026-11-02T19:08:40.5975923Z", + "scope": "Native consumption history review tasks and command receipt installation diagnostics", + "regression": { + "tool": "pest", + "result": "passed", + "tests": 59, + "passed": 59, + "assertions": 1030, + "duration_ms": 101725 + }, + "readiness": { + "tool": "pest", + "result": "passed", + "tests": 11, + "passed": 11, + "assertions": 130, + "duration_ms": 13016 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 7, + "passed": 7, + "assertions": 193, + "duration_ms": 34551 + }, + "http_proof": "history-task-integration-http.json", + "typescript": "passed", + "lint_build": "passed", + "phpstan": "passed", + "pint": "passed", + "runtime_hashes": 18, + "overlapping_suites_must_not_be_summed": true, + "visual_review": false, + "general_suite_rerun": false, + "account_or_grants_changed": false, + "sgf_installation_changed": false, + "limitations": [ + "Only the specific historical review permission grants execution; no live pilot grants were added", + "Normal history overview remains capped at 100; complete task pagination and focused source navigation cover older pending records", + "Current live pilot has no historical analyses; pending historical decisions were tested in disposable databases" + ], + "pilot_application_proof": "pilot-replenishment-activation.json", + "pilot_technical_installation_proof": "pilot-command-receipts-installation.json", + "pending_submission_proposal": "pilot-submission-proposal.json", + "submission_dry_run": "pilot-submission-dry-run.json" +} diff --git a/docs/proofs/inventory-access.json b/docs/proofs/inventory-access.json new file mode 100644 index 0000000..9a591ce --- /dev/null +++ b/docs/proofs/inventory-access.json @@ -0,0 +1,11 @@ +{ + "checked_at": "2026-11-02T22:56:50.3591107Z", + "visual_review": false, + "tests": 14, + "phpstan_errors": 0, + "pilot_business_commands": false, + "result": "passed", + "accounts_or_grants_changed": false, + "new_postgres_run": false, + "assertions": 263 +} diff --git a/docs/proofs/inventory-pagination-http.json b/docs/proofs/inventory-pagination-http.json new file mode 100644 index 0000000..9a571e2 --- /dev/null +++ b/docs/proofs/inventory-pagination-http.json @@ -0,0 +1,52 @@ +{ + "checked_at": "2026-11-02T22:55:27.773Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "path": "/stock/ocorrencias", + "status": 200 + }, + { + "role": "admin", + "path": "/stock/estornos", + "status": 200 + }, + { + "role": "warehouse", + "path": "/stock/ocorrencias", + "status": 200 + }, + { + "role": "warehouse", + "path": "/stock/estornos", + "status": 200 + }, + { + "role": "procurement", + "path": "/stock/ocorrencias", + "status": 403 + }, + { + "role": "procurement", + "path": "/stock/estornos", + "status": 403 + }, + { + "role": "finance", + "path": "/stock/ocorrencias", + "status": 403 + }, + { + "role": "finance", + "path": "/stock/estornos", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Pilot access and empty paginated/filtered lists; pending processes, native corrections and old sources tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/inventory-pagination-pg.json b/docs/proofs/inventory-pagination-pg.json new file mode 100644 index 0000000..ddde2c2 --- /dev/null +++ b/docs/proofs/inventory-pagination-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T22:50:08+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":10,\"passed\":10,\"assertions\":251,\"duration_ms\":187790}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/inventory-pagination.json b/docs/proofs/inventory-pagination.json new file mode 100644 index 0000000..cb0afc3 --- /dev/null +++ b/docs/proofs/inventory-pagination.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-02T22:52:47.0148123Z", + "result": "passed", + "scope": "inventory correction pagination filters and selected source continuity", + "regression": { + "assertions": 1085, + "tests": 55 + }, + "postgres_checks": { + "disposable_database_removed": true, + "assertions": 251, + "tests": 10 + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "frontend": { + "build": "passed", + "lint": "passed", + "types": "passed" + }, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "proof_files": [ + "inventory-pagination-pg.json", + "inventory-pagination-http.json" + ] +} diff --git a/docs/proofs/inventory-task-integration-http.json b/docs/proofs/inventory-task-integration-http.json new file mode 100644 index 0000000..2db7f00 --- /dev/null +++ b/docs/proofs/inventory-task-integration-http.json @@ -0,0 +1,52 @@ +{ + "checked_at": "2026-11-02T20:35:38.265Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "path": "/stock/ocorrencias", + "status": 200 + }, + { + "role": "admin", + "path": "/stock/estornos", + "status": 200 + }, + { + "role": "warehouse", + "path": "/stock/ocorrencias", + "status": 200 + }, + { + "role": "warehouse", + "path": "/stock/estornos", + "status": 200 + }, + { + "role": "procurement", + "path": "/stock/ocorrencias", + "status": 403 + }, + { + "role": "procurement", + "path": "/stock/estornos", + "status": 403 + }, + { + "role": "finance", + "path": "/stock/ocorrencias", + "status": 403 + }, + { + "role": "finance", + "path": "/stock/estornos", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "No persistent stock dispositions or reversals exist; pending decisions and physical movements tested in disposable databases only." +} \ No newline at end of file diff --git a/docs/proofs/inventory-task-integration-pg.json b/docs/proofs/inventory-task-integration-pg.json new file mode 100644 index 0000000..dee9997 --- /dev/null +++ b/docs/proofs/inventory-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T20:37:13+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":10,\"passed\":10,\"assertions\":441,\"duration_ms\":112434}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/inventory-task-integration.json b/docs/proofs/inventory-task-integration.json new file mode 100644 index 0000000..2a547b2 --- /dev/null +++ b/docs/proofs/inventory-task-integration.json @@ -0,0 +1,40 @@ +{ + "verification": { + "checked_at": "2026-11-02T20:44:38.1597529Z", + "typescript": "passed", + "lint_build": "passed", + "phpstan": "passed", + "pint": "passed", + "runtime_hashes": 18, + "visual_review": false, + "general_suite_rerun": false, + "persistent_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "overlapping_suites_must_not_be_summed": true + }, + "scope": "Disposition and full reversal task review, physical execution and blocked-source return routing through existing engines", + "consolidated_regression": { + "tool": "pest", + "result": "passed", + "tests": 86, + "passed": 86, + "assertions": 1244, + "duration_ms": 162828 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 10, + "passed": 10, + "assertions": 441, + "duration_ms": 112434 + }, + "http_proof": "inventory-task-integration-http.json", + "pilot_preservation": "warehouse-task-pilot-status-http.json", + "limitations": [ + "Readiness is a projection of native source validation; the writer rechecks all remaining physical and access conditions", + "General occurrence and reversal overviews retain their 100 record limit; tasks traverse all eligible records and focus bypasses this cap", + "No persistent stock corrections or physical movements were created for these checks" + ] +} diff --git a/docs/proofs/lot-pagination-http.json b/docs/proofs/lot-pagination-http.json new file mode 100644 index 0000000..cb2ac94 --- /dev/null +++ b/docs/proofs/lot-pagination-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-02T22:00:18.980Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200 + }, + { + "role": "warehouse", + "status": 403 + }, + { + "role": "procurement", + "status": 403 + }, + { + "role": "finance", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Read-only pilot access and empty pagination; 210 controls/history and pending-active pairs tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/lot-pagination-pg.json b/docs/proofs/lot-pagination-pg.json new file mode 100644 index 0000000..9b61f3b --- /dev/null +++ b/docs/proofs/lot-pagination-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T21:59:49+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":3,\"passed\":3,\"assertions\":98,\"duration_ms\":13012}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/lot-pagination.json b/docs/proofs/lot-pagination.json new file mode 100644 index 0000000..81eec7e --- /dev/null +++ b/docs/proofs/lot-pagination.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-02T22:02:54.5788979Z", + "result": "passed", + "scope": "lot control and history pagination with active/pending control pairs and coherent read", + "regression": { + "tests": 51, + "assertions": 821 + }, + "postgres_checks": { + "disposable_database_removed": true, + "tests": 3, + "assertions": 98 + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "frontend": { + "types": "passed", + "build": "passed", + "lint": "passed" + }, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "proof_files": [ + "lot-pagination-pg.json", + "lot-pagination-http.json" + ] +} diff --git a/docs/proofs/lot-quality-task-integration-http.json b/docs/proofs/lot-quality-task-integration-http.json new file mode 100644 index 0000000..9f24fef --- /dev/null +++ b/docs/proofs/lot-quality-task-integration-http.json @@ -0,0 +1,32 @@ +{ + "checked_at": "2026-11-02T21:52:05.174Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200, + "quality_tasks": 0 + }, + { + "role": "warehouse", + "status": 403, + "quality_tasks": 0 + }, + { + "role": "procurement", + "status": 403, + "quality_tasks": 0 + }, + { + "role": "finance", + "status": 403, + "quality_tasks": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Pilot has no active lot controls or operational quality grants; pending decisions and lot links exercised in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/lot-quality-task-integration-pg.json b/docs/proofs/lot-quality-task-integration-pg.json new file mode 100644 index 0000000..6d24ced --- /dev/null +++ b/docs/proofs/lot-quality-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T21:51:01+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":8,\"passed\":8,\"assertions\":106,\"duration_ms\":63017}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/lot-quality-task-integration.json b/docs/proofs/lot-quality-task-integration.json new file mode 100644 index 0000000..b373fd6 --- /dev/null +++ b/docs/proofs/lot-quality-task-integration.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-02T21:56:11.4818510Z", + "result": "passed", + "scope": "lot identification and quality task routing with existing warehouse and replenishment authorities", + "regression": { + "assertions": 1578, + "tests": 99 + }, + "postgres_task_checks": { + "tests": 8, + "assertions": 106, + "disposable_database_removed": true + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "frontend": { + "build": "passed", + "types": "passed", + "lint": "passed" + }, + "accounts_or_grants_changed": false, + "pilot_business_commands": false, + "sgf_changed": false, + "visual_review": false, + "proof_files": [ + "lot-quality-task-integration-pg.json", + "lot-quality-task-integration-http.json" + ] +} diff --git a/docs/proofs/lot-task-integration-http.json b/docs/proofs/lot-task-integration-http.json new file mode 100644 index 0000000..0545ef5 --- /dev/null +++ b/docs/proofs/lot-task-integration-http.json @@ -0,0 +1,32 @@ +{ + "checked_at": "2026-11-02T20:29:48.185Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200, + "lot_setup_tasks": 0 + }, + { + "role": "warehouse", + "status": 403, + "lot_setup_tasks": 0 + }, + { + "role": "procurement", + "status": 403, + "lot_setup_tasks": 0 + }, + { + "role": "finance", + "status": 403, + "lot_setup_tasks": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Live pilot has no lot setups or operational lot grants; pending configuration review tested only in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/lot-task-integration-pg.json b/docs/proofs/lot-task-integration-pg.json new file mode 100644 index 0000000..538473b --- /dev/null +++ b/docs/proofs/lot-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T20:40:57+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":6,\"passed\":6,\"assertions\":199,\"duration_ms\":42432}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/lot-task-integration.json b/docs/proofs/lot-task-integration.json new file mode 100644 index 0000000..c2332f3 --- /dev/null +++ b/docs/proofs/lot-task-integration.json @@ -0,0 +1,48 @@ +{ + "verification": { + "checked_at": "2026-11-02T20:44:38.1597529Z", + "typescript": "passed", + "lint_build": "passed", + "phpstan": "passed", + "pint": "passed", + "runtime_hashes": 18, + "visual_review": false, + "general_suite_rerun": false, + "persistent_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "overlapping_suites_must_not_be_summed": true + }, + "scope": "Independent review of lot setup and revisions, preserving current control while a proposal is pending", + "consolidated_regression_before_obsolete_draft_filter": { + "tool": "pest", + "result": "passed", + "tests": 86, + "passed": 86, + "assertions": 1244, + "duration_ms": 162828 + }, + "final_targeted_lot_tests": { + "tool": "pest", + "result": "passed", + "tests": 6, + "passed": 6, + "assertions": 199, + "duration_ms": 50775 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 6, + "passed": 6, + "assertions": 199, + "duration_ms": 42432 + }, + "http_proof": "lot-task-integration-http.json", + "pilot_preservation": "warehouse-task-pilot-status-http.json", + "limitations": [ + "Only setup activation tasks added; no automatic lot quality release, hold or technical identification assignment", + "Normal setup overview retains bounded views and history is limited to 200 records; task traversal and exact focus cover older pending configurations", + "Obsolete competing preparations remain in history and no longer project an executable task" + ] +} diff --git a/docs/proofs/matrix-reconciliation.json b/docs/proofs/matrix-reconciliation.json new file mode 100644 index 0000000..91240a3 --- /dev/null +++ b/docs/proofs/matrix-reconciliation.json @@ -0,0 +1,85 @@ +{ + "checked_at": "2026-11-03T07:05:22.9153495Z", + "scope": "SGC historical matrix reconciled against inspected code and existing evidence", + "result": "reconciled", + "basis": [ + { + "sha256": "6eb7e8a775904087ada55082c404e94ef931515023bbf998ab7f9b9277f229a6", + "path": "docs/compatibilidade.md" + }, + { + "sha256": "b1679d4c5c3058b5b406e4d6bfa5226731b52cdb7ed9ede446b1f7ab35be73d5", + "path": "docs/matriz-sgc-reconciliada.md" + }, + { + "sha256": "06959a998b01b576292fe72d85f2c729fa7549cbc42cb4c8c58681f4fc5a87a2", + "path": "docs/validacao-tecnica.md" + }, + { + "sha256": "e2e3e0e66fe685aa6c62270bd7cc23daae0876389403a140e4dee45771be664d", + "path": "docs/financeiro-consulta-paginada.md" + }, + { + "sha256": "7282da44eabd9cb1c76a9031462bd1b90bb6487f4540da7541196b400be55899", + "path": "docs/warehouse-unused-return.md" + }, + { + "sha256": "851a166729bafeb39b381b763d244f600f1aac24a569b29c25b67db8e353ced9", + "path": "docs/operacao-reposicao.md" + }, + { + "sha256": "33d687f8b5bfbd55e297a9b95bdd99b5c82d1a2c35bfce3fb474c38c4538b699", + "path": "config/fortify.php" + }, + { + "sha256": "23bc3af0fd4b67abb2f497742c06dff0208f3abcec1dd2c3338c15ff2abd3924", + "path": "app/Http/Controllers/QuotationController.php" + }, + { + "sha256": "5479aa53d9a7dbcb8259166d1053b437010756ed4966e713d684cac6a97618cb", + "path": "app/Http/Controllers/SupplyPlanningController.php" + }, + { + "sha256": "24dcdda538bee7e46356f26f93411e4f91e64e13db4bb6b09f88356c8bb18c1c", + "path": "app/Services/AutomaticPurchases.php" + }, + { + "sha256": "d1712029d7cedda6fbc9b0562bc108ebf8fb235d7aeb2bca4158639d3fc9a2a2", + "path": "app/Services/InventoryReversals.php" + }, + { + "sha256": "eaf57f4501b473485b7cf641d535f36f287e675206d812ec32cf5013cbb32f3c", + "path": "app/Services/SupplierPayablePage.php" + }, + { + "sha256": "79688aed32a712f543e573ab0305f630edb2e6cdba74969e4fe9b329d36d5746", + "path": "docs/proofs/replenishment-process-lock.json" + }, + { + "sha256": "a8e6f37bd2123c0c411874ddb01d6264db22d49ebc2b47d1f9d7be7581f110c8", + "path": "docs/proofs/task-deadlines.json" + }, + { + "sha256": "3847b696507c9d46ac58769a5cc4386fb0c2b4a1208efc6ba6c649ed3df89465", + "path": "docs/proofs/notice-consultation.json" + }, + { + "sha256": "3c33e06ad09df95b1930942a769bb17d0b6d69efab70e944b161c71f1c10220d", + "path": "docs/proofs/coordination-export.json" + }, + { + "sha256": "e4bb99bf2f957809abf09f24058b1845ac5f7781c79fa0f7d52803a8b55616fd", + "path": "docs/proofs/agreement-source.json" + }, + { + "sha256": "5e37edcc3759e753af6d5dfe326be061e4985243e1f1651148cf9360f855f9bd", + "path": "docs/proofs/pilot-evaluation-activation.json" + } + ], + "old_test_suites_rerun": false, + "full_document_coverage_certified": false, + "visual_review": false, + "commercial_acceptance": false, + "installation_or_grants_changed": false, + "current_increment_proof": "agreement-source.json" +} diff --git a/docs/proofs/mfa-foundation-http.json b/docs/proofs/mfa-foundation-http.json new file mode 100644 index 0000000..6f0bed1 --- /dev/null +++ b/docs/proofs/mfa-foundation-http.json @@ -0,0 +1,23 @@ +{ + "checked_at": "2026-11-03T07:16:51.829Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "passwordConfirmation": 200, + "twoFactorDisabled": true + }, + { + "role": "warehouse", + "passwordConfirmation": 200, + "twoFactorDisabled": true + } + ], + "enrollment_commands": false, + "password_changes": false, + "accounts_or_grants_changed": false, + "pilot_migration_applied": false, + "sgf_changed": false, + "visual_review": false +} \ No newline at end of file diff --git a/docs/proofs/mfa-foundation-pg.json b/docs/proofs/mfa-foundation-pg.json new file mode 100644 index 0000000..187e5e8 --- /dev/null +++ b/docs/proofs/mfa-foundation-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T07:16:05+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":6,\"passed\":6,\"assertions\":60,\"duration_ms\":7391}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/mfa-foundation.json b/docs/proofs/mfa-foundation.json new file mode 100644 index 0000000..429d569 --- /dev/null +++ b/docs/proofs/mfa-foundation.json @@ -0,0 +1,30 @@ +{ + "checked_at": "2026-11-03T07:17:59.9302381Z", + "scope": "Disabled-by-default native Fortify MFA foundation, schema and authentication challenge", + "result": "passed", + "regression": { + "assertions": 426, + "tests": 25, + "passed": 23, + "skipped": 2 + }, + "postgres": { + "assertions": 60, + "tests": 6, + "disposable_database_removed": true + }, + "frontend": { + "types": "passed", + "build": "passed", + "lint": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_mfa_enabled": false, + "pilot_migration_applied": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "full_enrollment_interface_complete": false, + "concurrent_recovery_replay_tested": false, + "visual_review": false +} diff --git a/docs/proofs/mfa-management-http.json b/docs/proofs/mfa-management-http.json new file mode 100644 index 0000000..c42482f --- /dev/null +++ b/docs/proofs/mfa-management-http.json @@ -0,0 +1,24 @@ +{ + "checked_at": "2026-11-03T07:28:11.276Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200, + "mfaDisabled": true + }, + { + "role": "warehouse", + "status": 200, + "mfaDisabled": true + } + ], + "enrollment_commands": false, + "password_changes": false, + "accounts_or_grants_changed": false, + "pilot_migration_applied": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Pilot remains disabled; enabled native management flows tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/mfa-management-pg.json b/docs/proofs/mfa-management-pg.json new file mode 100644 index 0000000..8482e38 --- /dev/null +++ b/docs/proofs/mfa-management-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T07:30:28+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":4,\"passed\":4,\"assertions\":68,\"duration_ms\":4802}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/mfa-management.json b/docs/proofs/mfa-management.json new file mode 100644 index 0000000..da72c02 --- /dev/null +++ b/docs/proofs/mfa-management.json @@ -0,0 +1,31 @@ +{ + "checked_at": "2026-11-03T07:34:26.5320011Z", + "scope": "Optional native MFA management UI and private account-owner operations, disabled in pilot", + "result": "passed", + "regression": { + "skipped": 1, + "passed": 28, + "assertions": 505, + "tests": 29 + }, + "postgres": { + "disposable_database_removed": true, + "assertions": 68, + "tests": 4 + }, + "frontend": { + "lint": "passed", + "build": "passed", + "types": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "csrf_validation_tested": true, + "pilot_mfa_enabled": false, + "pilot_migration_applied": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "concurrent_recovery_replay_tested": false, + "visual_review": false, + "mandatory_mfa_policy": false +} diff --git a/docs/proofs/needed-attention-http.json b/docs/proofs/needed-attention-http.json new file mode 100644 index 0000000..781d89b --- /dev/null +++ b/docs/proofs/needed-attention-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-03T08:26:34.773Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200 + }, + { + "role": "procurement", + "status": 200 + }, + { + "role": "finance", + "status": 200 + }, + { + "role": "warehouse", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing pilot report scopes; populated dates, pagination and revoked streaming access tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/needed-attention-pg.json b/docs/proofs/needed-attention-pg.json new file mode 100644 index 0000000..5e30a81 --- /dev/null +++ b/docs/proofs/needed-attention-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T08:24:16+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":71,\"duration_ms\":6895}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/needed-attention.json b/docs/proofs/needed-attention.json new file mode 100644 index 0000000..dd64b69 --- /dev/null +++ b/docs/proofs/needed-attention.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-03T08:28:56.1812750Z", + "scope": "Native needed-date attention filter, report indicator drilldown and complete CSV with refreshed stream authority", + "result": "passed", + "regression": { + "assertions": 726, + "passed": 29, + "tests": 29 + }, + "postgres": { + "assertions": 71, + "disposable_database_removed": true, + "tests": 2 + }, + "frontend": { + "types": "passed", + "build": "passed", + "lint": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "new_approval_deadlines": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/notice-consultation-http.json b/docs/proofs/notice-consultation-http.json new file mode 100644 index 0000000..d60b2ee --- /dev/null +++ b/docs/proofs/notice-consultation-http.json @@ -0,0 +1,109 @@ +{ + "checked_at": "2026-11-03T06:49:27.798Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "view": "all", + "status": 200, + "total": 0 + }, + { + "role": "admin", + "view": "unread", + "status": 200, + "total": 0 + }, + { + "role": "admin", + "view": "tasks", + "status": 200, + "total": 0 + }, + { + "role": "admin", + "view": "outcomes", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "view": "all", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "view": "unread", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "view": "tasks", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "view": "outcomes", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "view": "all", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "view": "unread", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "view": "tasks", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "view": "outcomes", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "view": "all", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "view": "unread", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "view": "tasks", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "view": "outcomes", + "status": 200, + "total": 0 + } + ], + "read_commands": false, + "business_decisions": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing synthetic profiles in read-only consultation; populated notice and mark-read flows tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/notice-consultation-pg.json b/docs/proofs/notice-consultation-pg.json new file mode 100644 index 0000000..f5e78de --- /dev/null +++ b/docs/proofs/notice-consultation-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T06:51:31+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":142,\"duration_ms\":30839}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/notice-consultation.json b/docs/proofs/notice-consultation.json new file mode 100644 index 0000000..740ec9a --- /dev/null +++ b/docs/proofs/notice-consultation.json @@ -0,0 +1,27 @@ +{ + "checked_at": "2026-11-03T06:52:14.5108572Z", + "scope": "Consistent private notice search and context-preserving native read acknowledgement", + "result": "passed", + "regression": { + "assertions": 757, + "tests": 24 + }, + "postgres": { + "tests": 2, + "disposable_database_removed": true, + "assertions": 142 + }, + "frontend": { + "lint": "passed", + "build": "passed", + "types": "passed after form call correction" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_notice_read_commands": false, + "pilot_business_decisions": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/pilot-approval-activation-http.json b/docs/proofs/pilot-approval-activation-http.json new file mode 100644 index 0000000..4c9f674 --- /dev/null +++ b/docs/proofs/pilot-approval-activation-http.json @@ -0,0 +1,31 @@ +{ + "checked_at": "2026-11-02T19:41:26.400Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "department_head", + "status": "approved", + "approval_task": false + }, + { + "role": "procurement", + "status": "approved", + "market_task": true, + "eligible_suppliers": 1 + }, + { + "role": "requester", + "status": "approved", + "approval_task": false + } + ], + "quantity": "2.000", + "total_minor_mzn": 2320, + "approved_by": 10, + "business_commands": false, + "supplier_selection_or_order": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false +} \ No newline at end of file diff --git a/docs/proofs/pilot-approval-activation.json b/docs/proofs/pilot-approval-activation.json new file mode 100644 index 0000000..5bb56e8 --- /dev/null +++ b/docs/proofs/pilot-approval-activation.json @@ -0,0 +1,25 @@ +{ + "result": "approved", + "approved_at": "2026-11-02 19:34:34", + "verified_at": "2026-11-02T19:47:40+00:00", + "environment": "http:\/\/127.0.0.1:8005", + "proposal_sha256": "cf015e4110db66d4543fd14a5b8769b80bb6369a94d75026d12c04936923a1e7", + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "status": "approved", + "approved_by": 10, + "quantity": "2.000", + "total_minor_mzn": 2320, + "market_consultation_task": true, + "budget_entries_preserved": true, + "backup": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-replenishment-20261102-193430-6b25f8\/before.dump", + "backup_sha256": "e15b50b70e7b18fed3645223c319320b0b7137ea317efc27694abcafc665f313", + "restored_tables_checked": 113, + "protected_tables_checked": 107, + "command_key": "50990058-e957-4eb2-a5c6-271d3ea63bda", + "verified_existing_committed_receipt_without_replay": true, + "supplier_selection_or_order": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "disposable_restore_removed": true +} diff --git a/docs/proofs/pilot-approval-authorization.json b/docs/proofs/pilot-approval-authorization.json new file mode 100644 index 0000000..5d7bbcb --- /dev/null +++ b/docs/proofs/pilot-approval-authorization.json @@ -0,0 +1,9 @@ +{ + "recorded_at": "2026-11-02T19:34:26.4470858Z", + "proposal_sha256": "cf015e4110db66d4543fd14a5b8769b80bb6369a94d75026d12c04936923a1e7", + "environment": "http://127.0.0.1:8005", + "owner_reply": "Autorizo a aprovação sintética da requisição para consulta ao mercado, sem seleccionar fornecedor nem emitir encomenda. Depois continua o desenvolvimento e a integração, só parando se precisar de outra autorização.", + "authorized_action": "approve only REQ-2026-000002 as existing synthetic department head 10", + "supplier_selection_award_orders_authorized": false, + "new_accounts_grants_sgf_changes_authorized": false +} diff --git a/docs/proofs/pilot-approval-dry-run.json b/docs/proofs/pilot-approval-dry-run.json new file mode 100644 index 0000000..6fb1a80 --- /dev/null +++ b/docs/proofs/pilot-approval-dry-run.json @@ -0,0 +1,18 @@ +{ + "checked_at": "2026-11-02T19:26:28+00:00", + "proposal_sha256": "cf015e4110db66d4543fd14a5b8769b80bb6369a94d75026d12c04936923a1e7", + "result": "passed", + "database": "disposable restored copy of local synthetic SGC", + "source_business_commands": false, + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "new_status_in_copy": "approved", + "approved_by_in_copy": 10, + "budget_reserved_minor": 2320, + "budget_committed_minor": 0, + "market_consultation_task_for_procurement": true, + "same_command_repeated_without_duplication": true, + "items_values_stock_orders_access_preserved": true, + "sgf_installation_changed": false, + "database_and_private_copy_removed": true +} diff --git a/docs/proofs/pilot-approval-proposal.json b/docs/proofs/pilot-approval-proposal.json new file mode 100644 index 0000000..ba83b0c --- /dev/null +++ b/docs/proofs/pilot-approval-proposal.json @@ -0,0 +1,21 @@ +{ + "status": "pending_owner_authorization", + "scope": "synthetic_local_pilot_only", + "environment": "http://127.0.0.1:8005", + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "approver_id": 10, + "current_role": "department_head", + "action": "approve", + "expected_current_status": "pending", + "quantity": "2.000", + "total_minor_mzn": 2320, + "expected_result": "approved_for_market_consultation", + "existing_budget_reservation_preserved_without_commitment": true, + "decision_comment": "Aprovação sintética da requisição de reposição ART-TEST, 2 caixas, estimativa 23,20 MZN com IVA, exclusivamente para validar o piloto local. Não constitui selecção de fornecedor ou encomenda.", + "change_items_or_values": false, + "supplier_selection_or_award_or_order": false, + "invoice_payment_or_stock_movement": false, + "new_accounts_or_grants": false, + "sgf_change": false +} diff --git a/docs/proofs/pilot-command-receipts-installation.json b/docs/proofs/pilot-command-receipts-installation.json new file mode 100644 index 0000000..3b9551b --- /dev/null +++ b/docs/proofs/pilot-command-receipts-installation.json @@ -0,0 +1,17 @@ +{ + "result": "installed", + "installed_at": "2026-11-02T19:04:40+00:00", + "environment": "http:\/\/127.0.0.1:8005", + "migration": "database\/migrations\/2026_11_01_000046_add_requisition_command_versions.php", + "backup": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-replenishment-20261102-190433-e3f169\/before.dump", + "backup_sha256": "e32297ad0287eb648c7760ce5544c4681e0d35921a4fb5152e663ae49a9045c3", + "restored_tables_checked": 112, + "protected_tables_checked": 110, + "existing_requisitions_preserved_except_initial_process_version": true, + "command_receipts": 0, + "immutability_triggers": 2, + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "disposable_restore_removed": true +} diff --git a/docs/proofs/pilot-evaluation-activation-http.json b/docs/proofs/pilot-evaluation-activation-http.json new file mode 100644 index 0000000..81357be --- /dev/null +++ b/docs/proofs/pilot-evaluation-activation-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-02T21:12:33.822Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "procurement", + "evaluations": 6, + "comparison_ready": true, + "comparison_task": true + }, + { + "role": "finance", + "evaluations": 6, + "comparison_ready": true, + "comparison_task": false + }, + { + "role": "department_head", + "evaluations": 6, + "comparison_ready": true, + "comparison_task": false + } + ], + "business_commands": false, + "supplier_selection_or_order": false, + "visual_review": false +} \ No newline at end of file diff --git a/docs/proofs/pilot-evaluation-activation.json b/docs/proofs/pilot-evaluation-activation.json new file mode 100644 index 0000000..a754c7b --- /dev/null +++ b/docs/proofs/pilot-evaluation-activation.json @@ -0,0 +1,32 @@ +{ + "result": "evaluations_registered", + "registered_at": "2026-11-02T21:09:06+00:00", + "environment": "http:\/\/127.0.0.1:8005", + "proposal_sha256": "9c6f5004c81030e1ea482e6f5c2c2bf4ed24638886cd9dbe8446e09a2a6f73fa", + "purchase_id": 2, + "evaluation_ids": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "evaluators": [ + 12, + 10 + ], + "evaluations": 6, + "grades": "5.00", + "comparison_ready": true, + "human_comparison_review_task": true, + "supplier_selection_award_or_order": false, + "outbound_communication": false, + "backup": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-replenishment-20261102-210858-a0a4c6\/before.dump", + "backup_sha256": "41c3d932c2d364da30841429267e2ebf3bf4ae79e416eaf54dda8603cb3def7d", + "restored_tables_checked": 113, + "protected_tables_checked": 111, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "disposable_restore_removed": true +} diff --git a/docs/proofs/pilot-evaluation-authorization.json b/docs/proofs/pilot-evaluation-authorization.json new file mode 100644 index 0000000..10dda50 --- /dev/null +++ b/docs/proofs/pilot-evaluation-authorization.json @@ -0,0 +1,16 @@ +{ + "recorded_at": "2026-11-02T21:08:41.0207312Z", + "proposal_sha256": "9c6f5004c81030e1ea482e6f5c2c2bf4ed24638886cd9dbe8446e09a2a6f73fa", + "environment": "http://127.0.0.1:8005", + "owner_reply": "Autorizo as seis avaliações fictícias com notas neutras de 5/10, pelas contas existentes de Finanças e chefe de departamento, sem seleccionar fornecedor ou encomendar. Depois continua a desenvolver e integrar.", + "evaluations": 6, + "evaluators": [ + 12, + 10 + ], + "quality": "5.00", + "history": "5.00", + "payment": "5.00", + "selection_award_orders_authorized": false, + "new_accounts_grants_sgf_changes_authorized": false +} diff --git a/docs/proofs/pilot-evaluation-dry-run.json b/docs/proofs/pilot-evaluation-dry-run.json new file mode 100644 index 0000000..a2b2e26 --- /dev/null +++ b/docs/proofs/pilot-evaluation-dry-run.json @@ -0,0 +1,35 @@ +{ + "checked_at": "2026-11-02T20:04:26+00:00", + "proposal_sha256": "9c6f5004c81030e1ea482e6f5c2c2bf4ed24638886cd9dbe8446e09a2a6f73fa", + "result": "passed", + "database": "disposable restored copy of local synthetic SGC", + "source_business_commands": false, + "evaluations_in_copy": 6, + "evaluators": [ + 12, + 10 + ], + "duplicate_evaluation_refused": true, + "comparison_ready": true, + "human_comparison_review_task": true, + "supplier_selection_award_or_order": false, + "protected_tables_unchanged": [ + "users", + "organization_access", + "organization_profiles", + "organization_memberships", + "suppliers", + "quotations", + "quotation_items", + "purchase_orders", + "stock_movements", + "requisitions", + "requisition_items", + "budget_entries", + "purchase_awards" + ], + "outbound_communication": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "database_and_private_copy_removed": true +} diff --git a/docs/proofs/pilot-evaluation-proposal.json b/docs/proofs/pilot-evaluation-proposal.json new file mode 100644 index 0000000..67692d5 --- /dev/null +++ b/docs/proofs/pilot-evaluation-proposal.json @@ -0,0 +1,28 @@ +{ + "status": "pending_owner_authorization", + "scope": "synthetic_local_pilot_only", + "environment": "http://127.0.0.1:8005", + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "quotation_ids": [2, 3, 4], + "evaluators": [12, 10], + "grades_for_each_quotation_and_each_evaluator": { + "quality": "5.00", + "history": "5.00", + "payment": "5.00", + "comment": "Avaliação inteiramente fictícia, com pontuações neutras para testar o percurso do piloto local. Não representa apreciação de qualidade, histórico ou condições comerciais reais.", + "no_conflict": true + }, + "expected_evaluations": 6, + "expected_minimum_qualified_evaluators_per_quotation": 2, + "price_delivery_weights_and_minima": "Reuse the existing quotation inputs and the comparison policy preserved on the approved requisition, without changing them.", + "expected_result": "comparison_ready_for_human_review", + "supplier_selection_award_or_order": false, + "change_requisition_quotes_supplier_records_or_stock": false, + "new_accounts_grants_or_sgf_change": false, + "outbound_communication": false, + "limitations": [ + "The six evaluations and their grades are artificial proposed test inputs, not real evidence of supplier performance.", + "No automatic selection, formal recommendation, award, order, invoice, payment, stock movement or change to existing approvals." + ] +} diff --git a/docs/proofs/pilot-market-activation-http.json b/docs/proofs/pilot-market-activation-http.json new file mode 100644 index 0000000..eaa890b --- /dev/null +++ b/docs/proofs/pilot-market-activation-http.json @@ -0,0 +1,35 @@ +{ + "checked_at": "2026-11-02T20:02:19.220Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "procurement", + "quotations": 3, + "evaluation_tasks": 3, + "pending_supplier_tasks": 0 + }, + { + "role": "finance", + "quotations": 3, + "evaluation_tasks": 3, + "pending_supplier_tasks": 0 + }, + { + "role": "department_head", + "quotations": 3, + "evaluation_tasks": 3, + "pending_supplier_tasks": 0 + } + ], + "totals_minor_mzn": [ + 2320, + 2552, + 2784 + ], + "business_commands": false, + "supplier_selection_or_order": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false +} \ No newline at end of file diff --git a/docs/proofs/pilot-market-activation.json b/docs/proofs/pilot-market-activation.json new file mode 100644 index 0000000..7c781e8 --- /dev/null +++ b/docs/proofs/pilot-market-activation.json @@ -0,0 +1,32 @@ +{ + "result": "market_registered", + "registered_at": "2026-11-02T19:56:43+00:00", + "environment": "http:\/\/127.0.0.1:8005", + "proposal_sha256": "99a4c5e4f0673cff8d1093768fedb1a95d389a0c497dacb7f60082f77057749d", + "purchase_id": 2, + "supplier_ids": { + "existing": 1, + "alternative_a": 2, + "alternative_b": 3 + }, + "quotation_ids": [ + 2, + 3, + 4 + ], + "totals_minor_mzn": [ + 2320, + 2552, + 2784 + ], + "evaluations": 0, + "supplier_selection_award_or_order": false, + "outbound_communication": false, + "backup": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-replenishment-20261102-195637-0f5dde\/before.dump", + "backup_sha256": "eacc5f0f5525d1537944cf16509e8a14b4080c20944e9d60d03ec9b23fc30615", + "restored_tables_checked": 113, + "protected_tables_checked": 109, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "disposable_restore_removed": true +} diff --git a/docs/proofs/pilot-market-authorization.json b/docs/proofs/pilot-market-authorization.json new file mode 100644 index 0000000..93d9819 --- /dev/null +++ b/docs/proofs/pilot-market-authorization.json @@ -0,0 +1,10 @@ +{ + "recorded_at": "2026-11-02T19:55:50.5741002Z", + "proposal_sha256": "99a4c5e4f0673cff8d1093768fedb1a95d389a0c497dacb7f60082f77057749d", + "environment": "http://127.0.0.1:8005", + "owner_reply": "Autorizo criar e validar os dois fornecedores fictícios e registar as três propostas de teste, sem avaliar, seleccionar fornecedor ou encomendar. Depois continua o desenvolvimento e a integração, só parando se precisar de outra autorização.", + "new_synthetic_suppliers": 2, + "synthetic_quotations": 3, + "evaluation_selection_award_orders_authorized": false, + "new_accounts_grants_sgf_changes_authorized": false +} diff --git a/docs/proofs/pilot-market-dry-run.json b/docs/proofs/pilot-market-dry-run.json new file mode 100644 index 0000000..08655c3 --- /dev/null +++ b/docs/proofs/pilot-market-dry-run.json @@ -0,0 +1,33 @@ +{ + "checked_at": "2026-11-02T19:56:02+00:00", + "proposal_sha256": "99a4c5e4f0673cff8d1093768fedb1a95d389a0c497dacb7f60082f77057749d", + "result": "passed", + "database": "disposable restored copy of local synthetic SGC", + "source_business_commands": false, + "new_synthetic_suppliers_in_copy": 2, + "supplier_preparation_and_review_independent": true, + "quotations_in_copy": 3, + "totals_minor_mzn": [ + 2320, + 2552, + 2784 + ], + "evaluations": 0, + "supplier_selection_award_or_order": false, + "protected_tables_unchanged": [ + "users", + "organization_access", + "organization_profiles", + "organization_memberships", + "purchase_orders", + "stock_movements", + "requisitions", + "requisition_items", + "budget_entries", + "purchase_awards" + ], + "outbound_communication": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "database_and_private_copy_removed": true +} diff --git a/docs/proofs/pilot-market-proposal.json b/docs/proofs/pilot-market-proposal.json new file mode 100644 index 0000000..87d8c41 --- /dev/null +++ b/docs/proofs/pilot-market-proposal.json @@ -0,0 +1,39 @@ +{ + "status": "pending_owner_authorization", + "scope": "synthetic_local_pilot_only", + "environment": "http://127.0.0.1:8005", + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "preparer_id": 11, + "supplier_reviewer_id": 12, + "existing_eligible_supplier_id": 1, + "new_synthetic_suppliers": [ + { "name": "Fornecedor fictício QA — alternativa A", "nuit": "990000021", "email": "fornecedor-a@example.test" }, + { "name": "Fornecedor fictício QA — alternativa B", "nuit": "990000022", "email": "fornecedor-b@example.test" } + ], + "synthetic_supplier_fields": { + "contact": "Contacto fictício do ensaio local", + "phone": "Não aplicável — ensaio sintético", + "address": "Morada fictícia para teste local; sem correspondência postal", + "category": "Ensaio sintético", + "beneficiaries": "Entidade inteiramente fictícia para ensaio; sem beneficiário real declarado", + "document_references": "Sem documentos reais: cadastro fictício para testar o percurso do SGC", + "diligence_notes": "Conferência exclusivamente sintética; não representa diligência, identidade fiscal ou validação comercial real.", + "blocked": false, + "next_review": "2026-11-09" + }, + "quotations": [ + { "supplier": "existing", "reference": "QA-MERCADO-2026-EXISTENTE", "unit_price": "10.00", "delivery_days": 2, "expected_total_minor": 2320 }, + { "supplier": "alternative_a", "reference": "QA-MERCADO-2026-A", "unit_price": "11.00", "delivery_days": 3, "expected_total_minor": 2552 }, + { "supplier": "alternative_b", "reference": "QA-MERCADO-2026-B", "unit_price": "12.00", "delivery_days": 4, "expected_total_minor": 2784 } + ], + "quotation_valid_until": "2026-11-09", + "payment_terms": "Condição inteiramente fictícia: pagamento após recepção, apenas para testar o piloto local", + "quantity_and_tax": "Reuse the existing approved line: 2 boxes, VAT 16%; do not change the requisition estimate.", + "limitations": [ + "The names, fiscal identifiers and commercial conditions are artificial test inputs, not identities verified against a real registry or offers received from suppliers.", + "Create two synthetic supplier records with Procurement and independently activate them with Finance; do not create accounts or grant permissions.", + "Register three synthetic quotations through the existing quotation workflow; do not select a supplier, evaluate proposals, award, issue an order or create invoices/payments/stock movements.", + "No email, invitation or contact with external parties; no SGF changes." + ] +} diff --git a/docs/proofs/pilot-replenishment-activation-http.json b/docs/proofs/pilot-replenishment-activation-http.json new file mode 100644 index 0000000..5ef8d34 --- /dev/null +++ b/docs/proofs/pilot-replenishment-activation-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-02T19:09:16.125Z", + "result": "passed", + "environment": "http://127.0.0.1:8005", + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "status": "draft", + "quantity": "2.000", + "total_minor_mzn": 2320, + "requester_task": true, + "automatic_source_reused": true, + "physical_milli": 1000, + "free_milli": 1000, + "checks": [ + { + "role": "procurement", + "result": "passed" + }, + { + "role": "finance", + "result": "passed" + } + ], + "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/pilot-replenishment-activation.json b/docs/proofs/pilot-replenishment-activation.json new file mode 100644 index 0000000..0cfb7f5 --- /dev/null +++ b/docs/proofs/pilot-replenishment-activation.json @@ -0,0 +1,22 @@ +{ + "result": "applied", + "applied_at": "2026-11-02T18:55:41+00:00", + "proposal_sha256": "2db14db3dbde06d1bf18ff3e2ae81bb857e5570a2baeafb36b22969e10963e1c", + "environment": "http:\/\/127.0.0.1:8005", + "policy_id": 2, + "rule_id": 4, + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "quantity": "2.000", + "total_minor_mzn": 2320, + "purchase_status": "draft", + "physical_milli_after": 1000, + "human_submission_and_approvals_pending": true, + "protected_tables_checked": 106, + "backup": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-replenishment-20261102-185536-c4396d\/before.dump", + "backup_sha256": "419b92072930553ef3f7b87bd08fc6e9bed1c48eb7ba1be97b50dd8977f12cd3", + "restored_tables_checked": 112, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "disposable_restore_removed": true +} diff --git a/docs/proofs/pilot-replenishment-authorization.json b/docs/proofs/pilot-replenishment-authorization.json new file mode 100644 index 0000000..e846a58 --- /dev/null +++ b/docs/proofs/pilot-replenishment-authorization.json @@ -0,0 +1,16 @@ +{ + "recorded_at": "2026-11-02T18:54:43.6485207Z", + "proposal_sha256": "2db14db3dbde06d1bf18ff3e2ae81bb857e5570a2baeafb36b22969e10963e1c", + "environment": "http://127.0.0.1:8005", + "scope": "synthetic_local_pilot_only", + "owner_reply": "Autorizo aplicar a proposta no piloto local com as contas sintéticas existentes. Depois continua o desenvolvimento e a integração, só parando se precisar de outra autorização.", + "authorized_actions": [ + "Publish only existing ART-TEST catalogue entry", + "Prepare and independently approve the proposed internal stock policy", + "Prepare and independently activate the proposed rule valid through 2026-11-09", + "Create exactly one requester draft for 2 boxes with estimated VAT-inclusive total MZN 23.20" + ], + "purchase_submission_or_approval_authorized": false, + "new_accounts_or_grants_authorized": false, + "sgf_changes_authorized": false +} diff --git a/docs/proofs/pilot-replenishment-dry-run.json b/docs/proofs/pilot-replenishment-dry-run.json new file mode 100644 index 0000000..756e792 --- /dev/null +++ b/docs/proofs/pilot-replenishment-dry-run.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-02T18:46:44+00:00", + "proposal_sha256": "2db14db3dbde06d1bf18ff3e2ae81bb857e5570a2baeafb36b22969e10963e1c", + "result": "passed", + "database": "disposable restored copy of local synthetic SGC", + "source_business_commands": false, + "accounts_or_grants_changed": false, + "drafts_created_in_copy": 1, + "quantity": "2.000", + "duplicate_approval_and_two_reevaluations": "one draft", + "estimated_total_minor_mzn": 2320, + "physical_milli_after": 1000, + "independent_policy_and_rule_tasks": true, + "requester_task": true, + "human_submission_and_approvals_pending": true, + "existing_evaluation_context_fallback_reproduced_only_in_copy_runtime": true, + "protected_tables_unchanged_in_copy": [ + "users", + "organization_access", + "organization_profiles", + "organization_memberships", + "approval_steps", + "purchase_orders", + "stock_movements" + ], + "sgf_installation_changed": false, + "visual_review": false, + "database_and_private_copy_removed": true +} diff --git a/docs/proofs/pilot-replenishment-proposal.json b/docs/proofs/pilot-replenishment-proposal.json new file mode 100644 index 0000000..714fa61 --- /dev/null +++ b/docs/proofs/pilot-replenishment-proposal.json @@ -0,0 +1,63 @@ +{ + "status": "pending_owner_authorization", + "environment": "http://127.0.0.1:8005", + "scope": "synthetic_local_pilot_only", + "source_checked_on": "2026-11-02", + "expected_access_version": 27, + "tenant_id": 1, + "source": { + "stock_item_id": 1, + "code": "ART-TEST", + "name": "Artigo de stock fictício", + "unit": "cx", + "warehouse_id": 1, + "warehouse_code": "ARM-TEST", + "physical_milli": 1000, + "free_milli": 1000, + "ledger_version": 1, + "catalogue_version": 0 + }, + "actors": { "catalogue": 13, "prepare": 11, "review": 12, "requester": 9 }, + "catalogue": { "category": "Ensaio sintético", "purchase_type": "other", "published": true }, + "policy": { + "stock_item_id": 1, + "warehouse_id": 1, + "forecast_method": "manual", + "purpose": "internal", + "origin": "local", + "safety_milli": 0, + "minimum_stock_milli": 1000, + "target_milli": 3000, + "maximum_milli": 3000, + "daily_milli": 0, + "lead_days": 1, + "approval_days": 1, + "horizon_days": 7, + "minimum_milli": 0, + "pack_milli": 1000, + "basis": "Valores artificiais propostos exclusivamente para o piloto sintético: saldo 1 cx, mínimo 1 cx, alvo e máximo 3 cx, sem previsão de consumo. Não são parâmetros comerciais nem histórico real." + }, + "rule": { + "requester_id": 9, + "cost_center_id": 1, + "unit_price": "10.00", + "tax_rate": "16.00", + "needed_days": 5, + "valid_until": "2026-11-09" + }, + "expected": { + "drafts_created": 1, + "quantity": "2.000", + "estimated_net_mzn": "20.00", + "estimated_tax_mzn": "3.20", + "estimated_total_mzn": "23.20", + "physical_milli_after": 1000, + "human_submission_and_approvals_pending": true + }, + "limitations": [ + "All new policy numbers and the 10 MZN price are artificial proposed test inputs, not approved business facts or a supplier quotation.", + "No new accounts, permissions, supplier agreements, purchase approvals, orders, invoices, deliveries or stock movements.", + "Rule validity ends on 2026-11-09; no assumed renewal.", + "No SGF installation or commercial entitlement changes." + ] +} diff --git a/docs/proofs/pilot-submission-activation-http.json b/docs/proofs/pilot-submission-activation-http.json new file mode 100644 index 0000000..5d2754c --- /dev/null +++ b/docs/proofs/pilot-submission-activation-http.json @@ -0,0 +1,26 @@ +{ + "checked_at": "2026-11-02T19:23:16.826Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "requester", + "status": "pending", + "task_count": 0, + "can_decide": false + }, + { + "role": "department_head", + "status": "pending", + "task_count": 1, + "can_decide": true + } + ], + "quantity": "2.000", + "total_minor_mzn": 2320, + "all_approval_steps_pending": 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/pilot-submission-activation.json b/docs/proofs/pilot-submission-activation.json new file mode 100644 index 0000000..c64cad0 --- /dev/null +++ b/docs/proofs/pilot-submission-activation.json @@ -0,0 +1,31 @@ +{ + "result": "submitted", + "submitted_at": "2026-11-02T19:20:53+00:00", + "environment": "http:\/\/127.0.0.1:8005", + "proposal_sha256": "1be6f272b7df84605a0f1c5d72d75103ab31ab31475a6d791da9bb607503183f", + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "status": "pending", + "quantity": "2.000", + "total_minor_mzn": 2320, + "current_role": "department_head", + "eligible_people": [ + { + "id": 10, + "name": "Chefe de departamento — avaliação local" + } + ], + "all_approval_steps_pending": true, + "command_key": "869e906c-12d9-431c-ac59-3624e25b7bca", + "backup": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-replenishment-20261102-192049-fc98ad\/before.dump", + "backup_sha256": "9ea78c59e6d22160ad889e62b7d252b99085fb378af123a53a0173f66b1727ba", + "budget_reserved_minor": 2320, + "budget_committed_minor": 0, + "other_budget_entries_and_replenishment_scopes_preserved": true, + "restored_tables_checked": 113, + "protected_tables_checked": 106, + "other_requisitions_preserved": true, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "disposable_restore_removed": true +} diff --git a/docs/proofs/pilot-submission-authorization.json b/docs/proofs/pilot-submission-authorization.json new file mode 100644 index 0000000..71d7984 --- /dev/null +++ b/docs/proofs/pilot-submission-authorization.json @@ -0,0 +1,12 @@ +{ + "recorded_at": "2026-11-02T19:18:41.8390058Z", + "proposal_sha256": "1be6f272b7df84605a0f1c5d72d75103ab31ab31475a6d791da9bb607503183f", + "environment": "http://127.0.0.1:8005", + "owner_reply": "autorizo", + "authorized_action": "submit only REQ-2026-000002 as existing synthetic requester 9", + "quantity": "2.000", + "total_minor_mzn": 2320, + "purchase_approval_authorized": false, + "orders_invoices_stock_changes_authorized": false, + "new_accounts_grants_or_sgf_changes_authorized": false +} diff --git a/docs/proofs/pilot-submission-dry-run.json b/docs/proofs/pilot-submission-dry-run.json new file mode 100644 index 0000000..823b596 --- /dev/null +++ b/docs/proofs/pilot-submission-dry-run.json @@ -0,0 +1,24 @@ +{ + "checked_at": "2026-11-02T19:05:58+00:00", + "proposal_sha256": "1be6f272b7df84605a0f1c5d72d75103ab31ab31475a6d791da9bb607503183f", + "result": "passed", + "database": "disposable restored copy of local synthetic SGC", + "source_business_commands": false, + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "new_status_in_copy": "pending", + "current_role": "department_head", + "eligible_people": [ + { + "id": 10, + "name": "Chefe de departamento — avaliação local" + } + ], + "all_approval_steps_pending": true, + "same_command_repeated_without_duplication": true, + "items_values_stock_orders_access_preserved": true, + "sgf_installation_changed": false, + "required_command_receipt_migration": "database\/migrations\/2026_11_01_000046_add_requisition_command_versions.php", + "command_receipt_migration_tested_in_copy": false, + "database_and_private_copy_removed": true +} diff --git a/docs/proofs/pilot-submission-proposal.json b/docs/proofs/pilot-submission-proposal.json new file mode 100644 index 0000000..695c7a5 --- /dev/null +++ b/docs/proofs/pilot-submission-proposal.json @@ -0,0 +1,18 @@ +{ + "status": "pending_owner_authorization", + "scope": "synthetic_local_pilot_only", + "environment": "http://127.0.0.1:8005", + "purchase_id": 2, + "purchase_number": "REQ-2026-000002", + "requester_id": 9, + "action": "submit", + "expected_current_status": "draft", + "quantity": "2.000", + "total_minor_mzn": 2320, + "expected_result": "pending_human_approval", + "change_items_or_values": false, + "purchase_approval": false, + "order_or_invoice_or_stock_movement": false, + "new_accounts_or_grants": false, + "sgf_change": false +} diff --git a/docs/proofs/policy-source-http.json b/docs/proofs/policy-source-http.json new file mode 100644 index 0000000..409e876 --- /dev/null +++ b/docs/proofs/policy-source-http.json @@ -0,0 +1,24 @@ +{ + "checked_at": "2026-11-03T05:17:02.536Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200 + }, + { + "role": "procurement", + "status": 403 + }, + { + "role": "finance", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Existing pilot access and lookup validation; positive source choices and suspensions tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/policy-source-pg.json b/docs/proofs/policy-source-pg.json new file mode 100644 index 0000000..bca78c3 --- /dev/null +++ b/docs/proofs/policy-source-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T05:16:15+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":66,\"duration_ms\":15618}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/policy-source.json b/docs/proofs/policy-source.json new file mode 100644 index 0000000..ec12aca --- /dev/null +++ b/docs/proofs/policy-source.json @@ -0,0 +1,25 @@ +{ + "phpstan_errors": 0, + "scope": "approved policy lookup for supply suggestions", + "runtime_hashes": 18, + "postgres_checks": { + "disposable_database_removed": true, + "tests": 2, + "assertions": 66 + }, + "result": "passed", + "frontend": { + "build": "passed", + "lint": "passed", + "types": "passed" + }, + "accounts_or_grants_changed": false, + "pilot_business_commands": false, + "visual_review": false, + "regression": { + "tests": 55, + "assertions": 1077 + }, + "checked_at": "2026-11-03T05:18:44.9666408Z", + "sgf_changed": false +} diff --git a/docs/proofs/receipt-block-installation-http.json b/docs/proofs/receipt-block-installation-http.json new file mode 100644 index 0000000..b3654d5 --- /dev/null +++ b/docs/proofs/receipt-block-installation-http.json @@ -0,0 +1,25 @@ +{ + "checked_at": "2026-11-03T09:51:27.431Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200, + "detailsChecked": 1 + }, + { + "role": "warehouse", + "status": 200, + "detailsChecked": 1 + }, + { + "role": "procurement", + "status": 200, + "detailsChecked": 1 + } + ], + "business_commands": false, + "documents_uploaded": false, + "visual_review": false +} \ No newline at end of file diff --git a/docs/proofs/receipt-block-installation.json b/docs/proofs/receipt-block-installation.json new file mode 100644 index 0000000..c73e0d0 --- /dev/null +++ b/docs/proofs/receipt-block-installation.json @@ -0,0 +1,15 @@ +{ + "checked_at": "2026-11-03T09:50:11+00:00", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "authorization": "User explicitly authorized receipt document block installation", + "migration": "database/migrations/2026_11_03_000002_create_receipt_documents.php", + "backup": "C:/laragon/data/sgf-tempo/backups/compras-receipt-documents-20261103-095000-a50bb8/before.dump", + "backup_sha256": "e12e74567574516b6c0699344cecf5ce99033a4d1d28e6fd06975d9a951ece5d", + "restored_tables_checked": 113, + "protected_tables_checked": 112, + "business_data_unchanged": true, + "documents_created": 0, + "mfa_enabled": false, + "restore_database_removed": true +} diff --git a/docs/proofs/receipt-block-pg.json b/docs/proofs/receipt-block-pg.json new file mode 100644 index 0000000..9b0dc5f --- /dev/null +++ b/docs/proofs/receipt-block-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T09:41:00+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":5,\"passed\":5,\"assertions\":78,\"duration_ms\":4468}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/receipt-block.json b/docs/proofs/receipt-block.json new file mode 100644 index 0000000..2d85dc9 --- /dev/null +++ b/docs/proofs/receipt-block.json @@ -0,0 +1,33 @@ +{ + "checked_at": "2026-11-03T09:41:56.4365262Z", + "block": "R1 receipt documents", + "regression": { + "tool": "pest", + "result": "passed", + "tests": 36, + "passed": 36, + "assertions": 271, + "duration_ms": 5147 + }, + "postgres": { + "checked_at": "2026-11-03T11:41:00+02:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":5,\"passed\":5,\"assertions\":78,\"duration_ms\":4468}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true + }, + "phpstan": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "typescript": "passed; receipt-block-types.txt", + "production_build": "passed; receipt-block-build.txt", + "pint": "passed", + "runtime_packages": "18 hashes verified", + "pilot_migration_applied": false, + "pilot_business_data_changed": false, + "full_visual_review": false, + "documentation": "docs/recepcao-documental.md" +} diff --git a/docs/proofs/replenishment-health-http.json b/docs/proofs/replenishment-health-http.json new file mode 100644 index 0000000..f3237cb --- /dev/null +++ b/docs/proofs/replenishment-health-http.json @@ -0,0 +1,21 @@ +{ + "checked_at": "2026-11-03T09:12:05.751Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200 + }, + { + "role": "warehouse", + "status": 403 + } + ], + "pilot_health_command_executed": false, + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing tenant-scoped diagnostic UI and rights preserved; CLI health executed only in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/replenishment-health-pg.json b/docs/proofs/replenishment-health-pg.json new file mode 100644 index 0000000..97daf7b --- /dev/null +++ b/docs/proofs/replenishment-health-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T09:10:37+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":6,\"passed\":6,\"assertions\":43,\"duration_ms\":8264}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/replenishment-health.json b/docs/proofs/replenishment-health.json new file mode 100644 index 0000000..8c18af3 --- /dev/null +++ b/docs/proofs/replenishment-health.json @@ -0,0 +1,26 @@ +{ + "checked_at": "2026-11-03T09:14:22.1818021Z", + "scope": "Read-only replenishment technical health, installation-bound observation and tenant-scoped UI compatibility", + "result": "passed", + "regression": { + "assertions": 473, + "tests": 46, + "passed": 46 + }, + "postgres": { + "disposable_database_removed": true, + "assertions": 43, + "tests": 6 + }, + "phpstan_errors": 0, + "pint": "passed", + "runtime_hashes": 18, + "frontend_changed": false, + "pilot_health_command_executed": false, + "pilot_business_commands": false, + "windows_service_installed": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/replenishment-heartbeat.json b/docs/proofs/replenishment-heartbeat.json new file mode 100644 index 0000000..61f5877 --- /dev/null +++ b/docs/proofs/replenishment-heartbeat.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-11-03T05:51:16.1989064Z", + "scope": "Native replenishment command observation and read-only warehouse diagnostics", + "result": "passed", + "tests": 36, + "assertions": 406, + "phpstan_errors": 0, + "pint": "passed", + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "windows_service_installed": false, + "sgf_changed": false, + "visual_review": false, + "postgres_execution": false +} diff --git a/docs/proofs/replenishment-process-lock.json b/docs/proofs/replenishment-process-lock.json new file mode 100644 index 0000000..7e2030a --- /dev/null +++ b/docs/proofs/replenishment-process-lock.json @@ -0,0 +1,23 @@ +{ + "checked_at": "2026-11-03T05:55:02.7402539Z", + "scope": "Local replenishment process exclusion and recovery after external worker termination", + "result": "passed", + "regression": { + "assertions": 425, + "tests": 39 + }, + "process_termination": { + "assertions": 5, + "tests": 1 + }, + "phpstan_errors": 0, + "pint": "passed", + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "windows_task_installed": false, + "sgf_changed": false, + "visual_review": false, + "postgres_execution": false, + "distributed_exclusion_tested": false +} diff --git a/docs/proofs/requisition-audit-http.json b/docs/proofs/requisition-audit-http.json new file mode 100644 index 0000000..9dc9212 --- /dev/null +++ b/docs/proofs/requisition-audit-http.json @@ -0,0 +1,32 @@ +{ + "checked_at": "2026-11-03T07:45:40.694Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "detail": 200, + "export": 200 + }, + { + "role": "procurement", + "detail": 200, + "export": 200 + }, + { + "role": "finance", + "detail": 200, + "export": 200 + }, + { + "role": "warehouse", + "detail": 404, + "export": 404 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Read-only metadata export of existing synthetic requisition 2, compared with native detail visibility." +} \ No newline at end of file diff --git a/docs/proofs/requisition-audit-pg.json b/docs/proofs/requisition-audit-pg.json new file mode 100644 index 0000000..09d2f83 --- /dev/null +++ b/docs/proofs/requisition-audit-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T07:44:24+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":139,\"duration_ms\":10454}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/requisition-audit.json b/docs/proofs/requisition-audit.json new file mode 100644 index 0000000..c789af6 --- /dev/null +++ b/docs/proofs/requisition-audit.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-03T07:47:45.0662905Z", + "scope": "Requisition-scoped audit metadata export sharing the native detail projection", + "result": "passed", + "regression": { + "passed": 35, + "assertions": 1016, + "tests": 35 + }, + "postgres": { + "disposable_database_removed": true, + "assertions": 139, + "tests": 2 + }, + "frontend": { + "lint": "passed", + "types": "passed", + "build": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "private_payload_exported": false, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "general_audit_export_complete": false, + "capacity_test": false +} diff --git a/docs/proofs/reservation-source-http.json b/docs/proofs/reservation-source-http.json new file mode 100644 index 0000000..d42d25d --- /dev/null +++ b/docs/proofs/reservation-source-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-02T23:05:42.276Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "status": 200 + }, + { + "role": "warehouse", + "status": 403 + }, + { + "role": "procurement", + "status": 403 + }, + { + "role": "finance", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Existing pilot access and source binding validation; positive searches and associations exercised in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/reservation-source-pg.json b/docs/proofs/reservation-source-pg.json new file mode 100644 index 0000000..a21be8a --- /dev/null +++ b/docs/proofs/reservation-source-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T23:04:43+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":3,\"passed\":3,\"assertions\":85,\"duration_ms\":46731}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/reservation-source.json b/docs/proofs/reservation-source.json new file mode 100644 index 0000000..9fdd3da --- /dev/null +++ b/docs/proofs/reservation-source.json @@ -0,0 +1,25 @@ +{ + "pilot_business_commands": false, + "checked_at": "2026-11-02T23:06:55.3329642Z", + "runtime_hashes": 18, + "scope": "planned reservation source search and existing stock issue reuse", + "result": "passed", + "postgres_checks": { + "assertions": 85, + "disposable_database_removed": true, + "tests": 3 + }, + "sgf_changed": false, + "visual_review": false, + "regression": { + "tests": 54, + "assertions": 971 + }, + "accounts_or_grants_changed": false, + "phpstan_errors": 0, + "frontend": { + "types": "passed", + "build": "passed", + "lint": "passed" + } +} diff --git a/docs/proofs/suggestion-task-integration-http.json b/docs/proofs/suggestion-task-integration-http.json new file mode 100644 index 0000000..f35d55c --- /dev/null +++ b/docs/proofs/suggestion-task-integration-http.json @@ -0,0 +1,27 @@ +{ + "checked_at": "2026-11-02T19:26:59.901Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "routes": [ + { + "role": "admin", + "suggestions_status": 200, + "suggestion_tasks": 0 + }, + { + "role": "procurement", + "suggestions_status": 403, + "suggestion_tasks": 0 + }, + { + "role": "finance", + "suggestions_status": 403, + "suggestion_tasks": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Live synthetic scope has no manual suggestions or specific execution grants; review, conversion and closure tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/suggestion-task-integration-pg.json b/docs/proofs/suggestion-task-integration-pg.json new file mode 100644 index 0000000..2e2c188 --- /dev/null +++ b/docs/proofs/suggestion-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T19:23:53+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":3,\"passed\":3,\"assertions\":142,\"duration_ms\":13876}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/suggestion-task-integration.json b/docs/proofs/suggestion-task-integration.json new file mode 100644 index 0000000..b9d4130 --- /dev/null +++ b/docs/proofs/suggestion-task-integration.json @@ -0,0 +1,40 @@ +{ + "checked_at": "2026-11-02T19:28:09.2616145Z", + "scope": "Native manual replenishment suggestion review, conversion and completion tasks", + "regression": { + "tool": "pest", + "result": "passed", + "tests": 50, + "passed": 50, + "assertions": 1024, + "duration_ms": 50049 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 3, + "passed": 3, + "assertions": 142, + "duration_ms": 13876 + }, + "http_proof": "suggestion-task-integration-http.json", + "typescript": "passed", + "lint_build": "passed", + "phpstan": "passed", + "pint": "passed", + "runtime_hashes": 18, + "overlapping_suites_must_not_be_summed": true, + "visual_review": false, + "general_suite_rerun": false, + "new_accounts_or_grants": false, + "sgf_installation_changed": false, + "limitations": [ + "Pending manual suggestion scenarios exercised only in disposable databases; current local pilot has no manual suggestion grants", + "Normal suggestions overview still limited to 100; tasks use complete source traversal and exact record focus", + "Completion task tested through rejected purchase; receipt completion authority reused from the existing workflow" + ], + "authorized_submission": "pilot-submission-activation.json", + "submission_http": "pilot-submission-activation-http.json", + "next_pending_proposal": "pilot-approval-proposal.json", + "next_disposable_proof": "pilot-approval-dry-run.json" +} diff --git a/docs/proofs/supplier-improvement-task-integration-http.json b/docs/proofs/supplier-improvement-task-integration-http.json new file mode 100644 index 0000000..181fd72 --- /dev/null +++ b/docs/proofs/supplier-improvement-task-integration-http.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-02T21:16:19.446Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "procurement", + "evaluation_page": 200, + "quality_evaluations": 2, + "improvement_tasks": 0 + }, + { + "role": "finance", + "evaluation_page": 200, + "quality_evaluations": 2, + "improvement_tasks": 0 + }, + { + "role": "department_head", + "evaluation_page": 200, + "quality_evaluations": 2, + "improvement_tasks": 0 + } + ], + "business_commands": false, + "created_quality_evaluations_or_measures": false, + "visual_review": false +} \ No newline at end of file diff --git a/docs/proofs/supplier-improvement-task-integration-pg.json b/docs/proofs/supplier-improvement-task-integration-pg.json new file mode 100644 index 0000000..5566f64 --- /dev/null +++ b/docs/proofs/supplier-improvement-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T21:15:06+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":5,\"passed\":5,\"assertions\":57,\"duration_ms\":10237}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/supplier-improvement-task-integration.json b/docs/proofs/supplier-improvement-task-integration.json new file mode 100644 index 0000000..28aaf83 --- /dev/null +++ b/docs/proofs/supplier-improvement-task-integration.json @@ -0,0 +1,28 @@ +{ + "checked_at": "2026-11-02T21:17:06.8676096Z", + "result": "passed", + "scope": "supplier improvement task routing", + "regression": { + "assertions": 819, + "tests": 52 + }, + "sqlite_task_checks": { + "assertions": 57, + "tests": 5 + }, + "postgres_task_checks": { + "disposable_database_removed": true, + "tests": 5, + "assertions": 57 + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "frontend_changed": false, + "pilot_quality_records_created": false, + "supplier_selection_or_order": false, + "visual_review": false, + "proof_files": [ + "supplier-improvement-task-integration-pg.json", + "supplier-improvement-task-integration-http.json" + ] +} diff --git a/docs/proofs/supplier-quality-task-integration-http.json b/docs/proofs/supplier-quality-task-integration-http.json new file mode 100644 index 0000000..4ed5415 --- /dev/null +++ b/docs/proofs/supplier-quality-task-integration-http.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-02T21:26:37.676Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "procurement", + "total_quality_tasks": 0, + "checked": [], + "proposal_evaluations": 6 + }, + { + "role": "finance", + "total_quality_tasks": 0, + "checked": [], + "proposal_evaluations": 6 + }, + { + "role": "department_head", + "total_quality_tasks": 0, + "checked": [], + "proposal_evaluations": 6 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "supplier_selection_or_order": false, + "visual_review": false +} \ No newline at end of file diff --git a/docs/proofs/supplier-quality-task-integration-pg.json b/docs/proofs/supplier-quality-task-integration-pg.json new file mode 100644 index 0000000..8910c4e --- /dev/null +++ b/docs/proofs/supplier-quality-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T21:26:24+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":11,\"passed\":11,\"assertions\":180,\"duration_ms\":34312}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/supplier-quality-task-integration.json b/docs/proofs/supplier-quality-task-integration.json new file mode 100644 index 0000000..79f7e77 --- /dev/null +++ b/docs/proofs/supplier-quality-task-integration.json @@ -0,0 +1,30 @@ +{ + "checked_at": "2026-11-02T21:27:13.6651413Z", + "result": "passed", + "scope": "supplier qualitative evaluations and responses task routing", + "regression": { + "tests": 76, + "assertions": 1276 + }, + "postgres_task_checks": { + "tests": 11, + "assertions": 180, + "disposable_database_removed": true + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "frontend": { + "lint": "passed", + "types": "passed", + "build": "passed" + }, + "accounts_or_grants_changed": false, + "new_pilot_business_commands": false, + "sgf_changed": false, + "supplier_selection_or_order": false, + "visual_review": false, + "proof_files": [ + "supplier-quality-task-integration-pg.json", + "supplier-quality-task-integration-http.json" + ] +} diff --git a/docs/proofs/supplier-task-integration-http.json b/docs/proofs/supplier-task-integration-http.json new file mode 100644 index 0000000..05c519c --- /dev/null +++ b/docs/proofs/supplier-task-integration-http.json @@ -0,0 +1,27 @@ +{ + "checked_at": "2026-11-02T20:07:58.997Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "procurement", + "pending_supplier_tasks": 0, + "supplier_detail_status": 200 + }, + { + "role": "finance", + "pending_supplier_tasks": 0, + "supplier_detail_status": 200 + }, + { + "role": "requester", + "pending_supplier_tasks": 0, + "supplier_detail_status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Current supplier records are active; pending review, stale forms and revoked authority tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/supplier-task-integration-pg.json b/docs/proofs/supplier-task-integration-pg.json new file mode 100644 index 0000000..c68e8af --- /dev/null +++ b/docs/proofs/supplier-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T20:05:23+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":5,\"passed\":5,\"assertions\":153,\"duration_ms\":18988}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/supplier-task-integration.json b/docs/proofs/supplier-task-integration.json new file mode 100644 index 0000000..fb50adb --- /dev/null +++ b/docs/proofs/supplier-task-integration.json @@ -0,0 +1,56 @@ +{ + "checked_at": "2026-11-02T20:09:31.9755742Z", + "scope": "Native supplier registry review tasks, shared authority and fresh actor checks", + "regression": { + "tool": "pest", + "result": "passed", + "tests": 94, + "passed": 94, + "assertions": 1276, + "duration_ms": 133014 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 5, + "passed": 5, + "assertions": 153, + "duration_ms": 18988 + }, + "fresh_actor_sqlite": { + "tool": "pest", + "result": "passed", + "tests": 1, + "passed": 1, + "assertions": 2, + "duration_ms": 2980 + }, + "private_detail_sqlite": { + "tool": "pest", + "result": "passed", + "tests": 1, + "passed": 1, + "assertions": 66, + "duration_ms": 7353 + }, + "http_proof": "supplier-task-integration-http.json", + "typescript": "passed", + "lint_build": "passed", + "phpstan": "passed", + "pint": "passed", + "runtime_hashes": 18, + "overlapping_suites_must_not_be_summed": true, + "visual_review": false, + "general_suite_rerun": false, + "new_accounts_or_grants": false, + "sgf_installation_changed": false, + "limitations": [ + "Current pilot supplier records are active; pending registry decisions exercised in disposable databases", + "Supplier task consultation does not activate a record or validate a fiscal identity", + "Only pending supplier records are projected; no invented periodic renewal assignments" + ], + "authorized_market_registration": "pilot-market-activation.json", + "market_http": "pilot-market-activation-http.json", + "next_pending_proposal": "pilot-evaluation-proposal.json", + "next_disposable_proof": "pilot-evaluation-dry-run.json" +} diff --git a/docs/proofs/supply-line-source-http.json b/docs/proofs/supply-line-source-http.json new file mode 100644 index 0000000..bbfb111 --- /dev/null +++ b/docs/proofs/supply-line-source-http.json @@ -0,0 +1,52 @@ +{ + "checked_at": "2026-11-03T05:39:36.099Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "view": "reservations", + "status": 200 + }, + { + "role": "admin", + "view": "deliveries", + "status": 200 + }, + { + "role": "warehouse", + "view": "reservations", + "status": 403 + }, + { + "role": "warehouse", + "view": "deliveries", + "status": 403 + }, + { + "role": "procurement", + "view": "reservations", + "status": 403 + }, + { + "role": "procurement", + "view": "deliveries", + "status": 403 + }, + { + "role": "finance", + "view": "reservations", + "status": 403 + }, + { + "role": "finance", + "view": "deliveries", + "status": 403 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Existing pilot access; positive canonical line searches and planning commands tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/supply-line-source-pg.json b/docs/proofs/supply-line-source-pg.json new file mode 100644 index 0000000..69e5efe --- /dev/null +++ b/docs/proofs/supply-line-source-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T05:37:33+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":3,\"passed\":3,\"assertions\":89,\"duration_ms\":6465}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/supply-line-source.json b/docs/proofs/supply-line-source.json new file mode 100644 index 0000000..800483a --- /dev/null +++ b/docs/proofs/supply-line-source.json @@ -0,0 +1,24 @@ +{ + "checked_at": "2026-11-03T05:43:13.9319354Z", + "scope": "canonical requisition and issued order line lookup for supply coordination", + "result": "passed", + "regression": { + "assertions": 1047, + "tests": 49 + }, + "postgres": { + "disposable_database_removed": true, + "assertions": 89, + "tests": 3 + }, + "frontend": { + "types": "passed", + "build": "passed", + "lint": "passed" + }, + "phpstan_errors": 0, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false +} diff --git a/docs/proofs/supply-pagination-http.json b/docs/proofs/supply-pagination-http.json new file mode 100644 index 0000000..3b4ad9d --- /dev/null +++ b/docs/proofs/supply-pagination-http.json @@ -0,0 +1,102 @@ +{ + "checked_at": "2026-11-02T22:24:03.604Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "pages": [ + { + "view": "agreements", + "status": 200, + "total": 0 + }, + { + "view": "calendar", + "status": 200, + "total": 1 + }, + { + "view": "history", + "status": 200, + "total": 0 + }, + { + "view": "suggestions", + "status": 200, + "total": 0 + }, + { + "view": "imports", + "status": 200, + "total": 0 + } + ] + }, + { + "role": "procurement", + "pages": [ + { + "view": "agreements", + "status": 200, + "total": 0 + }, + { + "view": "calendar", + "status": 200, + "total": 1 + }, + { + "view": "history", + "status": 403, + "total": null + }, + { + "view": "suggestions", + "status": 403, + "total": null + }, + { + "view": "imports", + "status": 403, + "total": null + } + ] + }, + { + "role": "finance", + "pages": [ + { + "view": "agreements", + "status": 200, + "total": 0 + }, + { + "view": "calendar", + "status": 200, + "total": 1 + }, + { + "view": "history", + "status": 403, + "total": null + }, + { + "view": "suggestions", + "status": 403, + "total": null + }, + { + "view": "imports", + "status": 403, + "total": null + } + ] + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_installation_changed": false, + "visual_review": false, + "coverage": "Current pilot pagination and existing role access; old records and selected sources exercised in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/supply-pagination-pg.json b/docs/proofs/supply-pagination-pg.json new file mode 100644 index 0000000..6f66b89 --- /dev/null +++ b/docs/proofs/supply-pagination-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T22:24:37+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":14,\"passed\":14,\"assertions\":356,\"duration_ms\":106380}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/supply-pagination.json b/docs/proofs/supply-pagination.json new file mode 100644 index 0000000..ebd7229 --- /dev/null +++ b/docs/proofs/supply-pagination.json @@ -0,0 +1,29 @@ +{ + "checked_at": "2026-11-02T22:27:01.3069176Z", + "result": "passed", + "scope": "supply record pagination, selected approved sources and import order search", + "regression": { + "tests": 73, + "assertions": 1378 + }, + "postgres_checks": { + "assertions": 356, + "tests": 14, + "disposable_database_removed": true + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "frontend": { + "lint": "passed", + "build": "passed", + "types": "passed" + }, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "proof_files": [ + "supply-pagination-pg.json", + "supply-pagination-http.json" + ] +} diff --git a/docs/proofs/supply-task-integration-http.json b/docs/proofs/supply-task-integration-http.json new file mode 100644 index 0000000..785e232 --- /dev/null +++ b/docs/proofs/supply-task-integration-http.json @@ -0,0 +1,56 @@ +{ + "checked_at": "2026-11-02T18:49:14.088Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "routes": [ + { + "role": "procurement", + "supply_tasks_shown": 0, + "task_links_followed": 0, + "components": [ + { + "path": "/compras/fornecimento?view=agreements", + "component": "supply/index", + "result": "passed" + }, + { + "path": "/compras/fornecimento?view=calendar", + "component": "supply/index", + "result": "passed" + }, + { + "path": "/stock/reposicao", + "component": "stock/replenishment", + "result": "passed" + } + ] + }, + { + "role": "finance", + "supply_tasks_shown": 0, + "task_links_followed": 0, + "components": [ + { + "path": "/compras/fornecimento?view=agreements", + "component": "supply/index", + "result": "passed" + }, + { + "path": "/compras/fornecimento?view=calendar", + "component": "supply/index", + "result": "passed" + }, + { + "path": "/stock/reposicao", + "component": "stock/replenishment", + "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/supply-task-integration-pg.json b/docs/proofs/supply-task-integration-pg.json new file mode 100644 index 0000000..915a0b5 --- /dev/null +++ b/docs/proofs/supply-task-integration-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-02T18:38:25+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":8,\"passed\":8,\"assertions\":262,\"duration_ms\":31631}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/supply-task-integration.json b/docs/proofs/supply-task-integration.json new file mode 100644 index 0000000..f8cc8b1 --- /dev/null +++ b/docs/proofs/supply-task-integration.json @@ -0,0 +1,41 @@ +{ + "verification": { + "checked_at": "2026-11-02T18:48:35.4905049Z", + "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": 48, + "passed": 48, + "assertions": 838, + "duration_ms": 34928 + }, + "postgres": { + "tool": "pest", + "result": "passed", + "tests": 8, + "passed": 8, + "assertions": 262, + "duration_ms": 31631 + }, + "http_proof": "supply-task-integration-http.json", + "scope": "Independent agreement, policy and automatic replenishment rule review tasks, exact current record focus and existing requester draft routing", + "limitations": [ + "Normal supply list still limited to 100; task queue covers all pending records and exact focus bypasses that limit", + "No automatic purchase submission or approval", + "Persistent QA had zero pending supply tasks; operational proposal remains pending authorization" + ], + "pilot_proposal": "pilot-replenishment-proposal.json", + "pilot_dry_run": "pilot-replenishment-dry-run.json" +} diff --git a/docs/proofs/task-deadlines-http.json b/docs/proofs/task-deadlines-http.json new file mode 100644 index 0000000..196deec --- /dev/null +++ b/docs/proofs/task-deadlines-http.json @@ -0,0 +1,132 @@ +{ + "checked_at": "2026-11-03T06:38:34.068Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "records": [ + { + "role": "admin", + "due": "all", + "status": 200, + "total": 1 + }, + { + "role": "admin", + "due": "overdue", + "status": 200, + "total": 0 + }, + { + "role": "admin", + "due": "today", + "status": 200, + "total": 0 + }, + { + "role": "admin", + "due": "upcoming", + "status": 200, + "total": 0 + }, + { + "role": "admin", + "due": "undated", + "status": 200, + "total": 1 + }, + { + "role": "warehouse", + "due": "all", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "due": "overdue", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "due": "today", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "due": "upcoming", + "status": 200, + "total": 0 + }, + { + "role": "warehouse", + "due": "undated", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "due": "all", + "status": 200, + "total": 1 + }, + { + "role": "procurement", + "due": "overdue", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "due": "today", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "due": "upcoming", + "status": 200, + "total": 0 + }, + { + "role": "procurement", + "due": "undated", + "status": 200, + "total": 1 + }, + { + "role": "finance", + "due": "all", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "due": "overdue", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "due": "today", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "due": "upcoming", + "status": 200, + "total": 0 + }, + { + "role": "finance", + "due": "undated", + "status": 200, + "total": 0 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "coverage": "Existing synthetic profiles and actual task projections; populated date boundary and pagination scenarios tested in disposable databases." +} \ No newline at end of file diff --git a/docs/proofs/task-deadlines-pg.json b/docs/proofs/task-deadlines-pg.json new file mode 100644 index 0000000..c072c60 --- /dev/null +++ b/docs/proofs/task-deadlines-pg.json @@ -0,0 +1,8 @@ +{ + "checked_at": "2026-11-03T06:38:10+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":2,\"passed\":2,\"assertions\":114,\"duration_ms\":67410}", + "races": [], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/task-deadlines.json b/docs/proofs/task-deadlines.json new file mode 100644 index 0000000..52b8052 --- /dev/null +++ b/docs/proofs/task-deadlines.json @@ -0,0 +1,26 @@ +{ + "checked_at": "2026-11-03T06:41:14.8961755Z", + "scope": "Existing task deadlines classified at consultation time across authorised modules", + "result": "passed", + "regression": { + "tests": 87, + "assertions": 2431 + }, + "postgres": { + "tests": 2, + "disposable_database_removed": true, + "assertions": 114 + }, + "frontend": { + "lint": "passed", + "types": "passed", + "build": "passed" + }, + "phpstan_errors": 0, + "runtime_hashes": 18, + "pilot_business_commands": false, + "accounts_or_grants_changed": false, + "sgf_changed": false, + "visual_review": false, + "capacity_test": false +} diff --git a/docs/proofs/warehouse-task-pilot-status-http.json b/docs/proofs/warehouse-task-pilot-status-http.json new file mode 100644 index 0000000..962a67a --- /dev/null +++ b/docs/proofs/warehouse-task-pilot-status-http.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-11-02T20:42:53.268Z", + "environment": "http://127.0.0.1:8005", + "result": "passed", + "purchase_id": 2, + "purchase_status": "approved", + "total_minor_mzn": 2320, + "quotations": 3, + "evaluations": 0, + "comparison_ready": false, + "physical_milli": 1000, + "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/recepcao-documental.md b/docs/recepcao-documental.md new file mode 100644 index 0000000..b04b997 --- /dev/null +++ b/docs/recepcao-documental.md @@ -0,0 +1,15 @@ +# Recepção documental — bloco R1 + +Entrega de 03/11/2026. O detalhe da recepção confirmada permite acrescentar uma guia, evidência de qualidade ou outro documento em PDF, JPEG ou PNG, até 2 MB. Apenas tipo e ficheiro são introduzidos: recepção, ordem, requisição, fornecedor, artigos e quantidades são reutilizados. O detalhe também liga ao processo de compra quando o utilizador pode consultá-lo. + +A escrita respeita a competência nativa de recepção, a independência do emissor da ordem e a restrição de manutenção. A consulta segue o acesso nativo à recepção e a empresa activa. O armazenamento é privado; caminhos internos não são enviados à interface. A descarga confere o SHA-256 antes de transmitir o ficheiro, força transferência e impede cache. Não existem comandos de alteração ou eliminação dos metadados, também protegidos por triggers na base. + +A chave de operação permite repetir uma submissão sem duplicar documentos ou auditoria. Uma nova submissão do mesmo conteúdo e tipo na mesma recepção reutiliza o documento e regista a reutilização. Conteúdo diferente é um novo documento: não substitui o anterior. Não existe deduplicação global entre recepções. + +Uma falha normal de transacção elimina o ficheiro que ficou sem registo. Uma interrupção abrupta do processo pode deixar um ficheiro privado sem referência; não foi implementada limpeza automática desses ficheiros. Este bloco não estabelece retenção geral, antivírus, assinatura digital, substituições de artigos, reabertura de qualidade nem cancelamento financeiro. + +## Validação e instalação + +Prova: docs/proofs/receipt-block.json. Bateria integrada de ReceiptDocumentBlockTest e ReceiptStockTest: 36 testes aprovados/271 verificações. PostgreSQL descartável: 5 testes/78 verificações, base eliminada no final. Cobertura de repetição, autorização, isolamento entre empresas/recepções, ficheiros inválidos, rollback, imutabilidade, integridade e ausência de alteração de stock/aprovações. TypeScript, compilação de produção e PHPStan passaram. Sem revisão visual completa ou ensaio de carga. + +A migração 2026_11_03_000002_create_receipt_documents.php não foi aplicada ao piloto. Sem a tabela, a recepção continua consultável e o painel informa indisponibilidade. A instalação exige autorização específica e preparação de salvaguarda e armazenamento; não deve activar incidentalmente a migração MFA pendente. Não foram criadas contas, permissões, ordens, recepções ou decisões no piloto. diff --git a/docs/relatorios-aprovadas-sem-documentos.md b/docs/relatorios-aprovadas-sem-documentos.md new file mode 100644 index 0000000..fe01448 --- /dev/null +++ b/docs/relatorios-aprovadas-sem-documentos.md @@ -0,0 +1,16 @@ +# Acompanhamento de requisições aprovadas + +03/11/2026. Relatórios de compras permite abrir as contagens operacionais em listas e CSV completos: + +- `attention=approved_without_quotes`: aprovadas sem propostas registadas. +- `attention=approved_without_order`: aprovadas sem ordem visível ao utilizador. + +O campo Acompanhamento reúne estas opções e a consulta já existente de necessidades ultrapassadas por aprovar. Não acrescenta campos de negócio nem altera o estado das requisições. As ligações dos indicadores conservam os filtros aplicados e reiniciam a página. + +Os filtros e as contagens reutilizam as mesmas consultas de propostas registadas e ordens visíveis. Os predicados são aplicados no servidor antes da paginação de 25; o CSV inclui todos os resultados. Mantêm-se empresa, departamento, competências actuais e revalidação do acesso ao gerar a exportação. + +As contagens podem sobrepor-se. Uma proposta expirada continua a ser uma proposta registada; a sua elegibilidade e avaliação pertencem ao percurso existente. Ausência de ordem visível não certifica ausência de qualquer documento nem autoriza emitir uma ordem. A consulta liga ao processo e ao relatório originais, sem criar rascunhos, tarefas ou decisões comerciais. + +Provas em `proofs/approved-attention.json` e variantes PostgreSQL/HTTP: mais de uma página, propostas expiradas registadas, intersecção com outros filtros, sobreposição, exportação integral, departamento/empresa e ausência de mutações. Os esquemas e dados PostgreSQL usados são descartáveis e a base é removida no final. + +Continuam separadas a revisão visual, a impressão, a capacidade sob carga e os restantes relatórios da matriz. Esta entrega não selecciona fornecedor, adjudica ou encomenda e não constitui aceitação comercial integral. diff --git a/docs/relatorios-contabilisticos-ensaio.md b/docs/relatorios-contabilisticos-ensaio.md new file mode 100644 index 0000000..07ba319 --- /dev/null +++ b/docs/relatorios-contabilisticos-ensaio.md @@ -0,0 +1,11 @@ +# Balancete e razão de ensaio — núcleo comum + +03/11/2026. Continuação de desenvolvimento interno, só consulta QA e direitos `general_ledger.view` existentes. O adaptador SGC usa o mesmo `LedgerReports`/`ReportsWorkspace` canónico do SGF, ou os ficheiros de runtime de um pacote fixado por hash, sem motor financeiro remoto. + +Rotas HTML/API `general-ledger/trial-balance` e `general-ledger/account-ledger`, com datas explícitas `from_on`/`to_on`, conta no razão e páginas25/100. Abertura, movimentos e fecho são calculados sobre todos os lançamentos do filtro. A janela SQL do razão precede a paginação; o saldo continua entre páginas. As leituras são repeatable-read/read-only, sem bloqueios de escrita e com autorização/contexto correntes. Âmbito indisponível/conta externa é recusado; schema ou capacidade de pacote ausente responde503, sem inventar saldo zero. + +Provas próprias em nova base de regressão: 21 verificações do serviço,20 HTTP e8 de isolamento; um reconhecimento60 pelo workflow e130 lançamentos explicitamente SQL-only para contas/paginação, sem outras dívidas ou liquidações. A observação adicional de5 verificações sobre o ciclo fornecedor completo anterior reconciliou contas e totais220MZN sem alterar o livro/período fechado. Não foram repetidas as suas decisões. + +Pacote QA novo de30 ficheiros, manifesto `cbc4ddbea15f5e396c92bc2e664b973138551928980f334bcf3eb0cf097cacb1`, provado noutra base nativa própria:6 verificações, núcleo/interface realmente carregados do artefacto e sem schema Sales. O pacote congelado anterior e8052 permanecem intactos e recusam o relatório ausente com503; não há actualização/activação do instalado. No ciclo canónico8051, a ligação ao balancete usa os dados já existentes da demonstração, sem os preencher automaticamente. + +Relatório completo e manifesto: `C:/laragon/www/sgf-tempo/docs/GENERAL_LEDGER_REPORTS_DELIVERY.md` e `docs/proofs/common-ledger-reports-20261103.json` no mesmo repositório canónico. Revisão visual, carga sustentada, implantação e validade legal/fiscal separadas. Sem novos direitos reais, piloto8005/candidatas/contas/dados alterados, publicação, checkpoint ou aceitação presumida. diff --git a/docs/relatorios-necessidades-por-aprovar.md b/docs/relatorios-necessidades-por-aprovar.md new file mode 100644 index 0000000..b545944 --- /dev/null +++ b/docs/relatorios-necessidades-por-aprovar.md @@ -0,0 +1,13 @@ +# Consulta de necessidades ultrapassadas ainda por aprovar + +03/11/2026. Em Relatórios de compras, o indicador operacional permite abrir a lista de pedidos em preparação ou aprovação cuja data de necessidade já passou. O mesmo filtro está disponível no formulário e no CSV. + +O filtro `attention=unapproved_needed` intersecta os restantes filtros existentes com estado `draft`/`pending` e `needed_by` anterior ao dia da consulta. Pedidos aprovados, rejeitados ou cancelados ficam excluídos; uma necessidade para hoje ainda não é ultrapassada. A data já registada aparece na linha e numa coluna adicional no fim do CSV, conservando as dez colunas anteriores. + +Página, contagens e indicadores usam todos os registos filtrados e a mesma referência temporal dentro da leitura consistente. A lista pagina 25; a exportação conserva todas as páginas. A passagem do tempo reavalia o filtro na consulta. A data de necessidade não é prazo atribuído ao aprovador nem prova de atraso de entrega. + +A exportação volta a verificar o utilizador, competências, manutenção e âmbito ao iniciar a leitura das linhas, incluindo quando o acesso mudou depois de preparar a resposta. As ordens visíveis são projectadas com esse mesmo titular explícito. A validação não substitui o controlo dos comandos de negócio. A consulta não submete, aprova, escolhe fornecedor, emite ordem ou movimenta stock. + +Provas `proofs/needed-attention.json` e variantes PostgreSQL/HTTP. Os testes PostgreSQL usam fixtures confirmadas apenas na base descartável para exercitar a transacção própria de leitura exigida pelo relatório; o esquema temporário é limpo entre casos e a base removida no final. Nenhuma salvaguarda transaccional da aplicação foi relaxada para os testes. + +Continuam pendentes os restantes relatórios da matriz, revisão visual, impressão e validação de capacidade. Este filtro fecha a ligação do indicador à consulta/exportação; não certifica cumprimento de prazos ou conclusão integral do SGC. diff --git a/resources/js/components/coordination-record-filters.tsx b/resources/js/components/coordination-record-filters.tsx new file mode 100644 index 0000000..d3733a6 --- /dev/null +++ b/resources/js/components/coordination-record-filters.tsx @@ -0,0 +1,81 @@ +import { Link, useForm } from '@inertiajs/react'; +import { controlClass } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; + +export type CoordinationFilters = { + record_search: string; + record_state: string; +}; + +export default function CoordinationRecordFilters({ + view, + filters, + states, +}: { + view: string; + filters: CoordinationFilters; + states: Record; +}) { + const form = useForm({ view, ...filters }); + return ( +
+ + Localizar registos por artigo, local, referência ou estado + +
{ + event.preventDefault(); + form.get('/compras/fornecimento', { + preserveState: true, + preserveScroll: true, + }); + }} + > + + + + + Limpar filtros + + {Object.entries(form.errors).map(([key, error]) => ( +

+ {error} +

+ ))} +
+
+ ); +} diff --git a/resources/js/components/correction-source-search.tsx b/resources/js/components/correction-source-search.tsx new file mode 100644 index 0000000..459ca6f --- /dev/null +++ b/resources/js/components/correction-source-search.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { controlClass } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; + +export default function CorrectionSourceSearch({ + initial = '', + search, + title = 'Procurar origem pela referência', + label = 'Número da recepção ou referência da operação', + disabled = false, +}: { + initial?: string; + search: (value: string) => void; + title?: string; + label?: string; + disabled?: boolean; +}) { + const [value, setValue] = useState(initial); + return ( +
+ {title} +
+ + +
+

+ As origens elegíveis já seleccionadas e os dados introduzidos + são conservados. +

+
+ ); +} diff --git a/resources/js/components/inventory-correction-filters.tsx b/resources/js/components/inventory-correction-filters.tsx new file mode 100644 index 0000000..3b10813 --- /dev/null +++ b/resources/js/components/inventory-correction-filters.tsx @@ -0,0 +1,74 @@ +import { Link, useForm } from '@inertiajs/react'; +import { controlClass } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; + +export type CorrectionFilters = { search: string; state: string }; + +export default function InventoryCorrectionFilters({ + path, + filters, +}: { + path: string; + filters: CorrectionFilters; +}) { + const form = useForm({ search: filters.search, state: filters.state }); + return ( +
+ + Filtrar por referência ou estado + +
{ + event.preventDefault(); + form.get(path, { + preserveState: true, + preserveScroll: true, + }); + }} + > + + + + + Limpar filtros + + {Object.entries(form.errors).map(([key, error]) => ( +

+ {error} +

+ ))} +
+
+ ); +} diff --git a/resources/js/components/procurement-operational-insights.tsx b/resources/js/components/procurement-operational-insights.tsx index 8e98566..040b37f 100644 --- a/resources/js/components/procurement-operational-insights.tsx +++ b/resources/js/components/procurement-operational-insights.tsx @@ -26,8 +26,14 @@ export type ProcurementInsights = { export function ProcurementOperationalInsights({ data, + neededPastHref, + withoutQuotesHref, + withoutOrderHref, }: { data: ProcurementInsights; + neededPastHref?: string; + withoutQuotesHref?: string; + withoutOrderHref?: string; }) { return ( @@ -51,6 +57,16 @@ export function ProcurementOperationalInsights({ precisa do bem ou serviço; não comprova atraso de entrega do fornecedor.

+ {neededPastHref && + data.unapproved_past_needed_date > 0 && ( + + Consultar pedidos por aprovar com data de + necessidade ultrapassada + + )} {data.pending.oldest.length > 0 && (

@@ -91,6 +107,27 @@ export function ProcurementOperationalInsights({ comparáveis; ausência de ordem visível não comprova incumprimento.

+
+ {withoutQuotesHref && + data.approved.without_registered_quotes > 0 && ( + + Consultar aprovadas sem propostas + registadas + + )} + {withoutOrderHref && + data.approved.without_visible_order > 0 && ( + + Consultar aprovadas sem ordem visível + + )} +
)}

diff --git a/resources/js/components/receipt-documents.tsx b/resources/js/components/receipt-documents.tsx new file mode 100644 index 0000000..b61d460 --- /dev/null +++ b/resources/js/components/receipt-documents.tsx @@ -0,0 +1,178 @@ +import { useForm } from '@inertiajs/react'; +import { useRef } from 'react'; +import { Panel, controlClass } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; +import { date } from '@/types/procurement'; + +export type ReceiptDocument = { + id: number; + document_type: string; + original_name: string; + size: number; + sha256: string; + uploaded_by: number; + created_at: string; +}; + +export default function ReceiptDocuments({ + receiptId, + ready, + canAdd, + documents, +}: { + receiptId: number; + ready: boolean; + canAdd: boolean; + documents: ReceiptDocument[]; +}) { + const input = useRef(null); + const form = useForm<{ + operation_key: string; + document_type: string; + file: File | null; + }>({ + operation_key: crypto.randomUUID(), + document_type: 'delivery_note', + file: null, + }); + const labels: Record = { + delivery_note: 'Guia de remessa', + quality: 'Evidência de qualidade', + other: 'Outro documento', + }; + return ( + +

+ {!ready ? ( +

+ Anexos ainda indisponíveis nesta instalação. +

+ ) : ( + <> + {documents.length === 0 && ( +

+ Sem anexos nesta recepção. +

+ )} +
    + {documents.map((document) => ( +
  • + + {document.original_name} + +

    + {labels[document.document_type]} ·{' '} + {Math.ceil(document.size / 1024)} KB · + Registado por #{document.uploaded_by} ·{' '} + {date(document.created_at)} +

    +
    + + Identificação do ficheiro preservado + + + SHA-256 {document.sha256} + +
    +
  • + ))} +
+ {canAdd && ( +
+ + Acrescentar evidência + +
{ + event.preventDefault(); + form.post( + `/recepcoes/${receiptId}/documentos`, + { + preserveScroll: true, + onSuccess: () => { + form.setData({ + operation_key: + crypto.randomUUID(), + document_type: + 'delivery_note', + file: null, + }); + if (input.current) + input.current.value = ''; + }, + }, + ); + }} + > + + +

+ Ficheiros idênticos do mesmo tipo são + reutilizados. Os documentos anteriores + permanecem preservados. +

+ {Object.entries(form.errors).map( + ([key, error]) => ( +

+ {error} +

+ ), + )} + +
+
+ )} + + )} + + ); +} diff --git a/resources/js/components/supply-pagination.tsx b/resources/js/components/supply-pagination.tsx new file mode 100644 index 0000000..01fa73e --- /dev/null +++ b/resources/js/components/supply-pagination.tsx @@ -0,0 +1,50 @@ +import { Link } from '@inertiajs/react'; + +export type SupplyPageInfo = { + current_page: number; + last_page: number; + total: number; + prev_page_url: string | null; + next_page_url: string | null; +}; + +export default function SupplyPagination({ + page, + label = 'Paginação dos registos de fornecimento', +}: { + page?: SupplyPageInfo | null; + label?: string; +}) { + if (!page) return null; + return ( + + ); +} diff --git a/resources/js/components/two-factor-management.tsx b/resources/js/components/two-factor-management.tsx new file mode 100644 index 0000000..8fce166 --- /dev/null +++ b/resources/js/components/two-factor-management.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +/** Secrets stay in this mounted component, never in page props or remembered state. */ +export default function TwoFactorManagement({ + enabled: initialEnabled, + pending: initialPending, +}: { + enabled: boolean; + pending: boolean; +}) { + const [enabled, setEnabled] = useState(initialEnabled); + const [pending, setPending] = useState(initialPending); + const [secret, setSecret] = useState(''); + const [code, setCode] = useState(''); + const [codes, setCodes] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [confirm, setConfirm] = useState<'disable' | 'regenerate' | null>( + null, + ); + + async function request( + path: string, + method = 'GET', + data?: Record, + ) { + const token = document.cookie + .split('; ') + .find((cookie) => cookie.startsWith('XSRF-TOKEN=')) + ?.slice(11); + const response = await fetch(path, { + method, + credentials: 'same-origin', + cache: 'no-store', + headers: { + Accept: 'application/json', + ...(method !== 'GET' + ? { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': decodeURIComponent(token ?? ''), + } + : {}), + }, + ...(data ? { body: JSON.stringify(data) } : {}), + }); + if (response.status === 423) + throw new Error( + 'Confirme novamente a palavra-passe, abrindo esta página outra vez.', + ); + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error( + response.status === 422 + ? (body?.errors?.code?.[0] ?? + 'Confirme os dados introduzidos.') + : 'Não foi possível concluir. Actualize a página e tente novamente.', + ); + } + return response.status === 204 ? null : response.json(); + } + async function run(action: () => Promise) { + setBusy(true); + setError(''); + try { + await action(); + } catch (failure) { + setError( + failure instanceof Error + ? failure.message + : 'Não foi possível concluir.', + ); + } finally { + setBusy(false); + } + } + const loadSecret = async () => { + const data = await request('/user/two-factor-secret-key'); + setSecret(data.secretKey); + }; + const loadCodes = async () => { + setCodes(await request('/user/two-factor-recovery-codes')); + }; + return ( +
+

+ Autenticação de dois factores +

+

+ {enabled + ? 'Activa. Nos próximos acessos será pedido o código da aplicação ou um código de recuperação.' + : pending + ? 'Adesão por confirmar. O segundo factor só será activado depois de confirmar um código válido.' + : 'Acrescente um código da aplicação autenticadora à palavra-passe desta conta.'} +

+ {error && ( +

+ {error}{' '} + + Reabrir segurança da conta + +

+ )} + {!enabled && !pending && ( + + )} + {pending && !secret && ( + + )} + {pending && secret && ( + <> +

+ Na aplicação autenticadora, adicione uma conta com esta + chave manual. Não partilhe esta chave. +

+ + {secret} + +
{ + event.preventDefault(); + void run(async () => { + await request( + '/user/confirmed-two-factor-authentication', + 'POST', + { code }, + ); + setEnabled(true); + setPending(false); + setSecret(''); + setCode(''); + await loadCodes(); + }); + }} + > + + +
+ + )} + {enabled && ( +
+ + +
+ )} + {codes.length > 0 && ( +
+

+ Guarde estes códigos num local privado. Cada um permite + recuperar um acesso e só pode ser usado uma vez. +

+
    + {codes.map((value) => ( +
  • {value}
  • + ))} +
+
+ )} + {(enabled || pending) && ( + + )} + {confirm && ( +
+

+ {confirm === 'disable' + ? 'Esta conta deixará de exigir o segundo factor. A palavra-passe permanece necessária.' + : 'Os códigos anteriores deixarão de funcionar. Guarde os novos códigos depois de confirmar.'} +

+ + +
+ )} +
+ ); +} diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx new file mode 100644 index 0000000..b4c71ad --- /dev/null +++ b/resources/js/pages/auth/confirm-password.tsx @@ -0,0 +1,41 @@ +import { Form, Head } from '@inertiajs/react'; +import { Button } from '@/components/ui/button'; +import PasswordInput from '@/components/password-input'; +import InputError from '@/components/input-error'; + +export default function ConfirmPassword() { + return ( + <> + +

Confirmar palavra-passe

+

+ Confirme a palavra-passe da sua conta para continuar esta + operação. +

+
+ {({ errors, processing }) => ( +
+ + + +
+ )} +
+ + ); +} diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx new file mode 100644 index 0000000..7aca2d3 --- /dev/null +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -0,0 +1,59 @@ +import { Form, Head } from '@inertiajs/react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/input-error'; + +export default function TwoFactorChallenge() { + const [recovery, setRecovery] = useState(false); + return ( + <> + +

Confirmar acesso à conta

+

+ {recovery + ? 'Introduza um dos códigos de recuperação guardados. Cada código só pode ser utilizado uma vez.' + : 'Introduza o código da aplicação de autenticação.'} +

+
+ {({ errors, processing }) => ( +
+ + + +
+ )} +
+ + + ); +} diff --git a/resources/js/pages/receipts/show.tsx b/resources/js/pages/receipts/show.tsx index 551c511..f152e92 100644 --- a/resources/js/pages/receipts/show.tsx +++ b/resources/js/pages/receipts/show.tsx @@ -4,15 +4,26 @@ import { PageTitle, Panel } from '@/components/procurement'; import { date } from '@/types/procurement'; import { quantity, qualityStates, receiptStates } from '@/types/inventory'; import type { Receipt, StockMovement } from '@/types/inventory'; +import ReceiptDocuments, { + type ReceiptDocument, +} from '@/components/receipt-documents'; export default function ReceiptShow({ receipt, movements, correctedReceiptItems = {}, + documentsReady = false, + canAddDocuments = false, + documents = [], + purchaseProcessHref = null, }: { receipt: Receipt; movements: StockMovement[]; correctedReceiptItems?: Record; + documentsReady?: boolean; + canAddDocuments?: boolean; + documents?: ReceiptDocument[]; + purchaseProcessHref?: string | null; }) { return ( <> @@ -24,6 +35,20 @@ export default function ReceiptShow({ description={`${receipt.snapshot.supplier.name} · ${receiptStates[receipt.status]}`} /> + + Imprimir ou guardar a recepção como PDF + +
@@ -122,6 +147,14 @@ export default function ReceiptShow({ > Consultar a ordem e as quantidades pendentes + {purchaseProcessHref && ( + + Acompanhar o processo de compra de origem + + )}
); diff --git a/resources/js/pages/reports/procurement.tsx b/resources/js/pages/reports/procurement.tsx index 61f578a..eecd927 100644 --- a/resources/js/pages/reports/procurement.tsx +++ b/resources/js/pages/reports/procurement.tsx @@ -16,6 +16,7 @@ type Row = { number: string; status: string; created_at: string; + needed_by: string | null; department: string | null; requester: string | null; total_minor: string; @@ -23,6 +24,7 @@ type Row = { order_number: string | null; }; type Filters = { + attention?: string; search?: string; status?: string; from?: string; @@ -61,11 +63,18 @@ export default function ProcurementReports({ from: filters.from ?? '', to: filters.to ?? '', department_id: String(filters.department_id ?? ''), + attention: filters.attention ?? '', }); const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters)) if (value !== null && value !== undefined && value !== '') params.set(key, String(value)); + const attentionParams = new URLSearchParams(params); + attentionParams.set('attention', 'unapproved_needed'); + const quoteParams = new URLSearchParams(params); + quoteParams.set('attention', 'approved_without_quotes'); + const orderParams = new URLSearchParams(params); + orderParams.set('attention', 'approved_without_order'); return ( <> @@ -83,7 +92,14 @@ export default function ProcurementReports({ } /> - {insights && } + {insights && ( + + )} +
diff --git a/resources/js/pages/settings/security.tsx b/resources/js/pages/settings/security.tsx index 8e4247d..879899e 100644 --- a/resources/js/pages/settings/security.tsx +++ b/resources/js/pages/settings/security.tsx @@ -4,6 +4,7 @@ import SecurityController from '@/actions/App/Http/Controllers/Settings/Security import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import PasswordInput from '@/components/password-input'; +import TwoFactorManagement from '@/components/two-factor-management'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { edit } from '@/routes/security'; @@ -11,6 +12,9 @@ import { edit } from '@/routes/security'; // oxfmt-ignore type Props = { passwordRules: string; + canManageTwoFactor?: boolean; + twoFactorEnabled?: boolean; + twoFactorPending?: boolean; }; export default function Security(props: Props) { @@ -24,6 +28,12 @@ export default function Security(props: Props) {

Segurança da conta

+ {props.canManageTwoFactor && ( + + )} ; canViewOperations: boolean; @@ -83,6 +101,12 @@ const states: Record = { }; function Prepare({ props, old }: { props: Props; old?: RecordEntry }) { + const [receipts, setReceipts] = useState(props.receipts); + const formKey = `disposition:${old?.id ?? 'new'}`; + useEffect(() => { + if (!props.sourceFormKey || props.sourceFormKey === formKey) + setReceipts(props.receipts); + }, [props.sourceFormKey, props.receipts, formKey]); const form = useForm({ operation_key: crypto.randomUUID(), record_key: old?.record_key, @@ -101,6 +125,27 @@ function Prepare({ props, old }: { props: Props; old?: RecordEntry }) { reference: old?.data.reference ?? '', }, }); + const searchSources = (value: string) => + router.get( + '/stock/ocorrencias', + { + records_page: props.recordPagination?.current_page, + search: props.filters.search || undefined, + state: props.filters.state || undefined, + ...(props.focusRecordId + ? { record_id: props.focusRecordId } + : {}), + source_search: value, + source_form_key: formKey, + selected_receipt_id: + form.data.data.receipt_item_id || undefined, + }, + { + only: ['receipts', 'sourceSearch', 'sourceFormKey'], + preserveState: true, + preserveScroll: true, + }, + ); const change = (update: Partial) => form.setData('data', { ...form.data.data, ...update }); const lots = props.lots.filter( @@ -119,6 +164,10 @@ function Prepare({ props, old }: { props: Props; old?: RecordEntry }) { }} > + + @@ -121,6 +150,16 @@ export default function Tasks({ Limpar filtros +

+ No módulo e pesquisa escolhidos, em {asOf}:{' '} + {Object.entries(deadlines) + .map( + ([value, label]) => + `${label}: ${deadlineCounts[value] ?? 0}`, + ) + .join(' · ')} + . +

{tasks.total} tarefa(s) no filtro. Documentos mais antigos aparecem primeiro; esta ordem não define @@ -162,6 +201,8 @@ export default function Tasks({

Prazo registado no processo:{' '} {task.due_on} + {' · '} + {deadlines[task.due_status]}

)} diff --git a/resources/views/receipts/print.blade.php b/resources/views/receipts/print.blade.php new file mode 100644 index 0000000..663875b --- /dev/null +++ b/resources/views/receipts/print.blade.php @@ -0,0 +1,59 @@ + + + + + + {{ $receipt->number }} · SGC + + + + @php($quantity = fn ($milli) => number_format($milli / 1000, 3, ',', '.')) + @php($quality = ['accepted' => 'Aceite', 'rejected' => 'Rejeitado', 'partial' => 'Parcial', 'pending' => 'Pendente']) + @php($types = ['delivery_note' => 'Guia de remessa', 'quality' => 'Qualidade', 'other' => 'Outro']) + +

Recepção {{ $receipt->number }}

+

Ordem {{ $receipt->snapshot['order']['number'] }} · Fornecedor: {{ $receipt->snapshot['supplier']['name'] }}

+ @if($receipt->snapshot['development_only'] ?? false) +

Documento de desenvolvimento. Não autoriza compras reais.

+ @endif +

Guia de remessa: {{ $receipt->delivery_note }}
+ Recebido em: {{ $receipt->received_on->format('Y-m-d') }} · Por: {{ $receipt->snapshot['receiver']['name'] }}
+ Armazém: {{ $receipt->snapshot['warehouse']['name'] ?? 'Sem entrada física' }}

+

Confirmação original de quantidade e qualidade

+

Esta reprodução conserva a confirmação original. Os estornos identificados abaixo devem ser considerados na leitura das quantidades; a situação efectiva consta da ordem.

+ + + + @foreach($receipt->items as $item) + + + + + + @endforeach + +
Artigo / unidadeRecebidoAceiteRejeitadoQualidade / observações
{{ $item->snapshot['order_item']['description'] }} · {{ $item->snapshot['order_item']['unit'] }} + @if($item->snapshot['stock_item'] ?? null)
{{ $item->snapshot['stock_item']['code'] }}@endif + @if(isset($corrections[$item->id]))

Estornado integralmente: {{ $corrections[$item->id]->data['reference'] }}

@endif +
{{ $quantity($item->received_milli) }}{{ $quantity($item->accepted_milli) }}{{ $quantity($item->rejected_milli) }}{{ $quality[$item->quality_status] ?? $item->quality_status }}

{{ $item->notes }}

+

Referências documentais

+

Os anexos permanecem no SGC e não são incorporados nesta impressão. A lista corresponde à consulta actual.

+ @forelse($documents as $document) +

{{ $types[$document->document_type] ?? $document->document_type }} · {{ $document->original_name }}
+ SHA-256: {{ $document->sha256 }}

+ @empty +

Sem referências documentais disponíveis para esta consulta.

+ @endforelse + + diff --git a/routes/web.php b/routes/web.php index 11c63c3..ac3c843 100644 --- a/routes/web.php +++ b/routes/web.php @@ -100,6 +100,7 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group( 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('compras/fornecimento.csv', [\App\Http\Controllers\SupplyCoordinationReportController::class, 'csv'])->name('supply.coordination.csv'); Route::get('stock/lotes', [InventoryLotController::class, 'index']); Route::post('stock/lotes', [InventoryLotController::class, 'save']); Route::get('stock/ocorrencias', [InventoryDispositionController::class, 'index']); @@ -164,6 +165,11 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group( Route::get('api/supplier-payments', [SupplierPaymentsController::class, 'index']); Route::post('api/supplier-payments/{action}', [SupplierPaymentsController::class, 'command'])->whereIn('action', ['payment-prepare', 'payment-decide', 'allocation-prepare', 'allocation-decide', 'reversal-prepare', 'reversal-confirm']); Route::get('api/supplier-payables', [SupplierPayablesController::class, 'index']); + Route::get('general-ledger/cycle', [\App\Http\Controllers\GeneralLedgerController::class,'page']); + Route::get('general-ledger/{kind}', [\App\Http\Controllers\GeneralLedgerController::class,'report'])->whereIn('kind',['trial-balance','account-ledger']); + Route::get('api/general-ledger/{kind}', [\App\Http\Controllers\GeneralLedgerController::class,'report'])->whereIn('kind',['trial-balance','account-ledger']); + Route::get('api/general-ledger/cycle', [\App\Http\Controllers\GeneralLedgerController::class,'index']); + Route::post('api/general-ledger/{action}', [\App\Http\Controllers\GeneralLedgerController::class,'command'])->whereIn('action',['prepare','validate','post','effect-prepare','correct-prepare','correct-validate','correct-post','period-prepare','period-validate','period-close']); Route::post('api/supplier-payables/{action}', [SupplierPayablesController::class, 'command'])->whereIn('action', ['prepare', 'validate', 'prepare_history', 'validate_history']); Route::get('politica-recomendacao', [FormalRecommendationController::class, 'policy'])->name('recommendations.policy'); Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']); @@ -179,6 +185,7 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group( Route::post('requisicoes/{requisition}/admissao/decisoes', [FormalAdmissionController::class, 'propose']); Route::post('admissoes/{decision}/conferir', [FormalAdmissionController::class, 'review']); Route::get('requisicoes/{requisition}/relatorio', [ProcurementReviewReportController::class, 'show'])->name('purchases.report'); + Route::get('requisicoes/{requisition}/historico.csv', [\App\Http\Controllers\RequisitionAuditController::class, 'csv'])->whereNumber('requisition')->name('requisitions.audit.csv'); Route::get('relatorios', [ProcurementReportController::class, 'index'])->name('reports.index'); Route::get('relatorios/compras.csv', [ProcurementReportController::class, 'export'])->name('reports.export'); Route::get('requisicoes/{requisition}/qualificacao', [DocumentaryQualificationController::class, 'show'])->name('qualifications.show'); @@ -224,6 +231,9 @@ Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group( Route::get('ordens/{order}/recepcoes', [GoodsReceiptController::class, 'order'])->name('receipts.order'); Route::post('ordens/{order}/recepcoes', [GoodsReceiptController::class, 'store'])->name('receipts.store'); Route::get('recepcoes/{receipt}', [GoodsReceiptController::class, 'show'])->name('receipts.show'); + Route::get('recepcoes/{receipt}/imprimir', [GoodsReceiptController::class, 'print'])->name('receipts.print'); + Route::post('recepcoes/{receipt}/documentos', [\App\Http\Controllers\ReceiptDocumentController::class, 'store'])->name('receipts.documents.store'); + Route::get('recepcoes/{receipt}/documentos/{document}', [\App\Http\Controllers\ReceiptDocumentController::class, 'download'])->whereNumber('document')->name('receipts.documents.download'); Route::get('stock', [StockController::class, 'index'])->name('stock.index'); Route::get('stock/operacoes', [StockOperationController::class, 'index'])->name('stock.operations'); Route::post('stock/operacoes', [StockOperationController::class, 'store'])->name('stock.operations.store'); diff --git a/scripts/agreement-source-http.mjs b/scripts/agreement-source-http.mjs new file mode 100644 index 0000000..a97e97f --- /dev/null +++ b/scripts/agreement-source-http.mjs @@ -0,0 +1,25 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['procurement',200],['finance',200],['warehouse',403]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const result=await viewer.request('/compras/fornecimento?view=calendar&agreement_form_key=prepare:new'); + assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true); + assert.equal(result.page.props.agreementFormKey,'prepare:new'); + if(!result.page.props.canPrepare) assert.deepEqual(result.page.props.agreements,[]); + assert.equal((await viewer.request('/compras/fornecimento?view=calendar&agreement_search=TEST')).status,422); + } + records.push({role,status:result.status}); + } + await fs.writeFile('docs/proofs/agreement-source-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing pilot access and required source scope; positive old source selection tested in disposable databases.'},null,2)); + console.log('Escolha de acordos conferida em leitura no piloto.'); +` + '})()')(fs, assert); diff --git a/scripts/alternative-task-integration-http.mjs b/scripts/alternative-task-integration-http.mjs new file mode 100644 index 0000000..668498b --- /dev/null +++ b/scripts/alternative-task-integration-http.mjs @@ -0,0 +1,25 @@ +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,expected] of [['admin',200],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const response=await viewer.request('/compras/fornecimento?view=alternatives');assert.equal(response.status,expected); + if(expected===200) { + assert.equal(response.page.component,'supply/coordination');assert.equal(response.page.encryptHistory,true); + assert.equal(response.page.props.records.length,0);assert.equal(response.page.props.canPrepare,false); + assert.equal(response.page.props.canReview,false); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=supply');assert.equal(tasks.status,200); + const pending=tasks.page.props.tasks.data.filter(row=>row.task.startsWith('alternative_'));assert.equal(pending.length,0); + routes.push({role,alternatives_status:response.status,alternative_tasks:pending.length}); + } + await fs.writeFile('docs/proofs/alternative-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:'Live synthetic scope has no technical alternatives or specific execution grants; technical review, independence and source changes tested in disposable databases.'},null,2)); + console.log('Alternativas tecnicas e tarefas conferidas, preservando competencias especificas.'); +` + '})()')(fs, assert); diff --git a/scripts/apply-pilot-replenishment-proposal.php b/scripts/apply-pilot-replenishment-proposal.php new file mode 100644 index 0000000..a16dd5a --- /dev/null +++ b/scripts/apply-pilot-replenishment-proposal.php @@ -0,0 +1,400 @@ + json_decode(ltrim(file_get_contents($root.'/docs/proofs/'.$name.'.json'), "\xEF\xBB\xBF"), true, 64, JSON_THROW_ON_ERROR); +$proposal = $read('pilot-replenishment-proposal'); +$tested = $read('pilot-replenishment-dry-run'); +$authorization = $read('pilot-replenishment-authorization'); +$hash = hash_file('sha256', $root.'/docs/proofs/pilot-replenishment-proposal.json'); +$apply = in_array('--apply-authorized', $argv, true); +$installCommands = in_array('--install-command-receipts', $argv, true); +$submitPurchase = in_array('--submit-purchase', $argv, true); +$approvePurchase = in_array('--approve-purchase', $argv, true); +$marketScenario = in_array('--market', $argv, true); +$evaluationScenario = in_array('--evaluations', $argv, true); +$evaluation = $evaluationScenario ? $read('pilot-evaluation-proposal') : null; +if ($evaluationScenario) { + $evaluationHash = hash_file('sha256', $root.'/docs/proofs/pilot-evaluation-proposal.json'); + $evaluationTested = $read('pilot-evaluation-dry-run'); + $evaluationAuthorization = $read('pilot-evaluation-authorization'); + if ($marketScenario || $approvePurchase || $submitPurchase || $installCommands || $evaluationTested['result'] !== 'passed' + || $evaluationTested['proposal_sha256'] !== $evaluationHash || $evaluationAuthorization['proposal_sha256'] !== $evaluationHash + || $evaluationAuthorization['environment'] !== $evaluation['environment'] || $evaluation['scope'] !== 'synthetic_local_pilot_only') { + throw new LogicException('Avaliações exigem a proposta exacta ensaiada e autorizada.'); + } +} +$market = $marketScenario ? $read('pilot-market-proposal') : null; +if ($marketScenario) { + $marketHash = hash_file('sha256', $root.'/docs/proofs/pilot-market-proposal.json'); + $marketTested = $read('pilot-market-dry-run'); + $marketAuthorization = $read('pilot-market-authorization'); + if ($approvePurchase || $submitPurchase || $installCommands || $marketTested['result'] !== 'passed' || $marketTested['proposal_sha256'] !== $marketHash + || $marketAuthorization['proposal_sha256'] !== $marketHash || $marketAuthorization['environment'] !== $market['environment'] + || $market['scope'] !== 'synthetic_local_pilot_only' || $market['quotation_valid_until'] < date('Y-m-d')) { + throw new LogicException('Consulta sintética exige a proposta exacta ensaiada e autorizada.'); + } +} +$verifyApproved = in_array('--verify-approval-committed', $argv, true); +if ($verifyApproved && ! $approvePurchase) { + throw new LogicException('Verificação limitada à aprovação autorizada.'); +} +$approval = $approvePurchase ? $read('pilot-approval-proposal') : null; +if ($approvePurchase) { + $approvalHash = hash_file('sha256', $root.'/docs/proofs/pilot-approval-proposal.json'); + $approvalAuthorization = $read('pilot-approval-authorization'); + $approvalTested = $read('pilot-approval-dry-run'); + if ($installCommands || $submitPurchase || $approvalTested['result'] !== 'passed' || $approvalTested['proposal_sha256'] !== $approvalHash + || $approvalAuthorization['proposal_sha256'] !== $approvalHash || $approvalAuthorization['environment'] !== $approval['environment'] + || $approval['action'] !== 'approve' || $approval['scope'] !== 'synthetic_local_pilot_only') { + throw new LogicException('Aprovação exige a proposta exacta ensaiada e autorizada.'); + } +} +$submission = $submitPurchase ? $read('pilot-submission-proposal') : null; +if ($submitPurchase) { + $submissionHash = hash_file('sha256', $root.'/docs/proofs/pilot-submission-proposal.json'); + $submissionAuthorization = $read('pilot-submission-authorization'); + $submissionTested = $read('pilot-submission-dry-run'); + if ($installCommands || $submissionTested['result'] !== 'passed' || $submissionTested['proposal_sha256'] !== $submissionHash + || $submissionAuthorization['proposal_sha256'] !== $submissionHash || $submissionAuthorization['owner_reply'] !== 'autorizo' + || $submissionAuthorization['environment'] !== $submission['environment'] || $submission['action'] !== 'submit') { + throw new LogicException('Submissão exige a proposta exacta ensaiada e autorizada.'); + } +} +if ($tested['result'] !== 'passed' || $tested['proposal_sha256'] !== $hash || $authorization['proposal_sha256'] !== $hash + || $authorization['environment'] !== 'http://127.0.0.1:8005' || $proposal['environment'] !== $authorization['environment'] + || $proposal['scope'] !== 'synthetic_local_pilot_only' || $proposal['rule']['valid_until'] < date('Y-m-d')) { + throw new LogicException('Proposta, ensaio e autorização têm de coincidir e estar vigentes.'); +} +$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-compras.json'), true, 32, JSON_THROW_ON_ERROR); +if (($settings['APP_ENV'] ?? '') !== 'payable-testing' || ($settings['DB_HOST'] ?? '') !== '127.0.0.1' + || realpath($settings['HOST_ROOT'] ?? '') !== realpath($root) + || ! preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D', $settings['DB_DATABASE'] ?? '', $match) + || ($settings['DB_USERNAME'] ?? '') !== 'payable_qa_'.$match[1]) { + throw new LogicException('Aplicação exclusivamente na avaliação local autorizada.'); +} +$app = require $root.'/bootstrap/app.php'; +$app->make(Kernel::class)->bootstrap(); +$owner = DB::connection('pgsql')->getConfig(); +unset($owner['name']); +if ($owner['host'] !== '127.0.0.1') { + throw new LogicException('Restauro de controlo 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.replenishment_apply' => $qa, 'database.connections.replenishment_owner' => [...$owner, 'url' => null, 'database' => 'postgres'], + 'app.env' => 'payable-testing', 'work_context.composition' => 'sgc']); +$app->detectEnvironment(fn () => 'payable-testing'); +DB::setDefaultConnection('replenishment_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('Destino diferente do autorizado.'); +} +$request = Request::create($proposal['environment'].'/stock/reposicao', 'GET', server: ['REMOTE_ADDR' => '127.0.0.1']); +$request->setLaravelSession($app['session']->driver('array')); +$app->instance('request', $request); +$request->setUserResolver(fn ($guard = null) => auth()->guard($guard)->user()); +$actor = function (string $task) use ($proposal, $request) { + $user = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($proposal['actors'][$task]); + auth()->guard('web')->setUser($user); + $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $user->id, 'tenant_id' => $user->tenant_id]); + + return $user; +}; +$check = function (bool $valid, string $message) { + if (! $valid) { + throw new LogicException($message); + } +}; +$fingerprint = function ($connection, array $tables) { + $result = []; + foreach ($tables as $table) { + $quoted = '"'.str_replace('"', '""', $table).'"'; + $rows = $connection->select('select row_to_json(t)::text as row from '.$quoted.' t order by row_to_json(t)::text'); + $result[$table] = hash('sha256', json_encode(array_column($rows, 'row'), JSON_THROW_ON_ERROR)); + } + + return $result; +}; +$pg = function (string $binary, array $arguments, array $connection) { + $process = new Process(['C:/Program Files/PostgreSQL/17/bin/'.$binary.'.exe', ...$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('Cópia/restauro de controlo não concluído.'); + } +}; +$restore = null; +$failure = null; +$admin = DB::connection('replenishment_owner'); +try { + $result = $db->transaction(function () use ($db, $actor, $check, $proposal, $hash, $apply, $installCommands, $submitPurchase, $submission, $approvePurchase, $verifyApproved, $approval, $marketScenario, $market, $evaluationScenario, $evaluation, $app, $fingerprint, $pg, $qa, $owner, $admin, &$restore) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + app(ConflictGuard::class)->lockCompany($actor('catalogue')); + $check((int) DB::table('organization_access')->where('tenant_id', $proposal['tenant_id'])->lockForUpdate()->value('version') === $proposal['expected_access_version'], 'Os acessos mudaram.'); + $stock = StockItem::query()->lockForUpdate()->findOrFail($proposal['source']['stock_item_id']); + $warehouse = Warehouse::query()->findOrFail($proposal['source']['warehouse_id']); + $check($stock->only(['code', 'name', 'unit']) === array_intersect_key($proposal['source'], array_flip(['code', 'name', 'unit'])) && $warehouse->code === $proposal['source']['warehouse_code'], 'Cadastro diferente da proposta.'); + $balance = app(WarehouseAvailability::class)->row($stock, $warehouse, false); + $check($balance['physical_milli'] === $proposal['source']['physical_milli'] && $balance['free_milli'] === $proposal['source']['free_milli'] && $balance['ledger_version'] === $proposal['source']['ledger_version'], 'Disponibilidade diferente da proposta.'); + $planning = app(SupplyPlanning::class); + if ($evaluationScenario) { + $purchase = Requisition::query()->lockForUpdate()->findOrFail($evaluation['purchase_id']); + $quotes = Quotation::query()->where('requisition_id', $purchase->id)->orderBy('id')->get(); + $check($purchase->number === $evaluation['purchase_number'] && $purchase->status === 'approved' + && $quotes->pluck('id')->all() === $evaluation['quotation_ids'] && $quotes->sum(fn ($quote) => $quote->evaluations()->count()) === 0, + 'Compra, propostas ou avaliações já alteradas; não reaplicar.'); + } elseif ($marketScenario) { + $purchase = Requisition::query()->lockForUpdate()->findOrFail($market['purchase_id']); + $check($purchase->number === $market['purchase_number'] && $purchase->status === 'approved' && $purchase->total_minor === 2320 + && Quotation::query()->where('requisition_id', $purchase->id)->count() === 0, 'Consulta já iniciada ou compra alterada; não duplicar.'); + $check(! Supplier::query()->whereIn('nuit', array_column($market['new_synthetic_suppliers'], 'nuit'))->exists(), 'Cadastro sintético já existe; não reaplicar.'); + } elseif ($approvePurchase) { + $purchase = Requisition::query()->lockForUpdate()->findOrFail($approval['purchase_id']); + $check($purchase->number === $approval['purchase_number'] && $purchase->status === ($verifyApproved ? 'approved' : 'pending') && $purchase->items()->sole()->quantity === $approval['quantity'] + && $purchase->total_minor === $approval['total_minor_mzn'] && $purchase->steps()->count() === 1 && $purchase->steps()->sole()->role === $approval['current_role'], 'Compra ou etapa diferente da proposta.'); + } elseif ($submitPurchase) { + $purchase = Requisition::query()->lockForUpdate()->findOrFail($submission['purchase_id']); + $check($purchase->number === $submission['purchase_number'] && $purchase->requester_id === $submission['requester_id'] && $purchase->status === 'draft' + && $purchase->items()->sole()->quantity === $submission['quantity'] && $purchase->total_minor === $submission['total_minor_mzn'] && $purchase->steps()->count() === 0, + 'Rascunho diferente da proposta; não repetir submissão.'); + } elseif ($installCommands) { + $testedSubmission = json_decode(file_get_contents(base_path('docs/proofs/pilot-submission-dry-run.json')), true, 32, JSON_THROW_ON_ERROR); + $check($testedSubmission['result'] === 'passed' && $testedSubmission['command_receipt_migration_tested_in_copy'] === true, 'Instalação tem de estar ensaiada na cópia.'); + $check(! Schema::hasTable('requisition_commands') && ! Schema::hasColumn('requisitions', 'process_version'), 'Instalação já existe ou está parcial; não reaplicar.'); + } else { + $check(! DB::table('requisition_catalogue')->where('tenant_id', $proposal['tenant_id'])->where('stock_item_id', $stock->id)->exists(), 'Artigo já publicado/configurado; não reaplicar.'); + foreach (['policy', 'auto_rule'] as $kind) { + $check(! $planning->latest($kind)->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id)->exists(), 'Já existe parametrização; não duplicar.'); + } + } + if (! $apply) { + return ['result' => 'preflight_passed_no_changes', 'proposal_sha256' => $hash]; + } + $tables = array_map(fn ($r) => $r->tablename, $db->select("select tablename from pg_tables where schemaname='public' order by tablename")); + $baseline = $fingerprint($db, $tables); + $protected = array_values(array_diff($tables, ['requisition_catalogue', 'supply_records', 'requisitions', 'requisition_items', 'audit_logs', 'replenishment_work'])); + if ($verifyApproved) { + // Original consistent pre-decision backup, retained when a post-commit callback failed. + $dump = 'C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-20261102-193430-6b25f8/before.dump'; + $check(is_file($dump), 'Cópia original de recuperação não disponível.'); + } else { + $directory = 'C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)); + $check(mkdir($directory, 0700, true), 'Não foi possível preparar a cópia de recuperação.'); + $dump = $directory.'/before.dump'; + $snapshot = $db->selectOne('select pg_export_snapshot() as snapshot')->snapshot; + $pg('pg_dump', ['--format=custom', '--snapshot='.$snapshot, '--file='.$dump, '--dbname='.$qa['database']], $qa); + } + $restore = 'compras_pilot_restore_'.bin2hex(random_bytes(6)); + $admin->statement('CREATE DATABASE "'.$restore.'"'); + $pg('pg_restore', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], $owner); + config(['database.connections.replenishment_restore' => [...$owner, 'url' => null, 'database' => $restore]]); + $restoredBaseline = $fingerprint(DB::connection('replenishment_restore'), $tables); + if ($verifyApproved) { + $baseline = $restoredBaseline; + } else { + $check($restoredBaseline === $baseline, 'Restauro diferente da origem.'); + } + if ($evaluationScenario) { + $evaluationTable = (new ProposalEvaluation)->getTable(); + $protected = array_values(array_diff($tables, [$evaluationTable, 'audit_logs'])); + foreach ($evaluation['evaluators'] as $id) { + $evaluator = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($id); + auth()->guard('web')->setUser($evaluator); + request()->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $evaluator->id, 'tenant_id' => $evaluator->tenant_id]); + foreach ($quotes as $quote) { + $input = Request::create($evaluation['environment'].'/cotacoes/'.$quote->id.'/avaliar', 'POST', $evaluation['grades_for_each_quotation_and_each_evaluator']); + $input->setUserResolver(fn () => $evaluator); + app(QuotationController::class)->evaluate($input, $quote, app(RequisitionWorkflow::class)); + } + } + $records = ProposalEvaluation::query()->whereIn('quotation_id', $evaluation['quotation_ids'])->orderBy('id')->get(); + $check($records->count() === 6 && $records->every(fn ($record) => in_array($record->evaluator_id, $evaluation['evaluators'], true) + && (string) $record->quality === '5.00' && (string) $record->history === '5.00' && (string) $record->payment === '5.00'), 'Avaliações diferentes das autorizadas.'); + $check($fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'Alteração fora da avaliação autorizada; cancelar.'); + $oldEvaluations = DB::connection('replenishment_restore')->table($evaluationTable)->orderBy('id')->get()->toJson(); + $check(DB::table($evaluationTable)->whereNotIn('id', $records->modelKeys())->orderBy('id')->get()->toJson() === $oldEvaluations, 'Avaliações anteriores alteradas.'); + $preparer = $actor('prepare'); + $comparison = app(ProposalComparison::class)->inspect($purchase); + $check($comparison['ready'] && collect(app(WorkflowTaskInbox::class)->build($preparer, 'procurement'))->contains('id', 'quotation:'.$purchase->id.':result'), 'Sem encaminhamento da comparação para conferência humana.'); + + return ['result' => 'evaluations_registered', 'registered_at' => now()->toIso8601String(), 'environment' => $evaluation['environment'], + 'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-evaluation-proposal.json')), 'purchase_id' => $purchase->id, + 'evaluation_ids' => $records->modelKeys(), 'evaluators' => $evaluation['evaluators'], 'evaluations' => 6, 'grades' => '5.00', + 'comparison_ready' => true, 'human_comparison_review_task' => true, 'supplier_selection_award_or_order' => false, 'outbound_communication' => false, + 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected), + 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + } + if ($marketScenario) { + $protected = array_values(array_diff($tables, ['suppliers', 'quotations', 'quotation_items', 'audit_logs'])); + require_once __DIR__.'/support/pilot-market-scenario.php'; + $scenario = pilotMarketScenario($app, $proposal, $market, $actor, $check); + $check($fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'Alteração fora da consulta autorizada; cancelar.'); + $oldQuotes = DB::connection('replenishment_restore')->table('quotations')->orderBy('id')->get()->toJson(); + $check(DB::table('quotations')->whereNotIn('id', $scenario['quotation_ids'])->orderBy('id')->get()->toJson() === $oldQuotes, 'Propostas anteriores alteradas.'); + + return ['result' => 'market_registered', 'registered_at' => now()->toIso8601String(), 'environment' => $market['environment'], + 'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-market-proposal.json')), 'purchase_id' => $purchase->id, + 'supplier_ids' => $scenario['supplier_ids'], 'quotation_ids' => $scenario['quotation_ids'], 'totals_minor_mzn' => [2320, 2552, 2784], + 'evaluations' => 0, 'supplier_selection_award_or_order' => false, 'outbound_communication' => false, + 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected), + 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + } + if ($approvePurchase) { + $protected = array_values(array_diff($tables, ['requisitions', 'approval_steps', 'requisition_commands', 'audit_logs', 'workflow_notices', 'replenishment_work'])); + $othersBefore = DB::table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson(); + $approver = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($approval['approver_id']); + auth()->guard('web')->setUser($approver); + request()->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $approver->id, 'tenant_id' => $approver->tenant_id]); + if ($verifyApproved) { + $receipt = DB::table('requisition_commands')->where('tenant_id', $approver->tenant_id)->where('actor_id', $approver->id)->get() + ->filter(fn ($row) => (json_decode($row->result, true, 64, JSON_THROW_ON_ERROR)['id'] ?? null) === $purchase->id)->sole(); + $recorded = json_decode($receipt->result, true, 64, JSON_THROW_ON_ERROR); + $check($recorded['status'] === 'approved' && $purchase->steps()->sole()->decision === 'approved' + && $purchase->steps()->sole()->comment === $approval['decision_comment'], 'Recibo ou decisão diferente da autorização.'); + $commandKey = $receipt->operation_key; + $othersBefore = DB::connection('replenishment_restore')->table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson(); + } else { + $check($purchase->canDecide($approver), 'A competência actual não permite a decisão.'); + $commandKey = (string) Str::uuid(); + app(RequisitionWorkflow::class)->decide($approver, $purchase, 'approved', $approval['decision_comment'], $commandKey, (int) $purchase->process_version); + } + $purchase->refresh(); + $check($purchase->status === 'approved' && $purchase->steps()->sole()->approver_id === $approver->id + && $fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)) + && DB::table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson() === $othersBefore, 'Alteração fora da decisão autorizada; cancelar.'); + $check(collect(app(WorkflowTaskInbox::class)->build($actor('prepare'), 'procurement'))->contains('id', 'quotation:'.$purchase->id.':prepare'), 'Sem encaminhamento para consulta ao mercado.'); + // Keep the command actor until after-commit callbacks have finished. + auth()->guard('web')->setUser($approver); + request()->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $approver->id, 'tenant_id' => $approver->tenant_id]); + + return ['result' => 'approved', 'approved_at' => (string) $purchase->steps()->sole()->decided_at, 'verified_at' => now()->toIso8601String(), 'environment' => $approval['environment'], + 'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-approval-proposal.json')), 'purchase_id' => $purchase->id, + 'purchase_number' => $purchase->number, 'status' => $purchase->status, 'approved_by' => $approver->id, 'quantity' => $purchase->items()->sole()->quantity, + 'total_minor_mzn' => $purchase->total_minor, 'market_consultation_task' => true, 'budget_entries_preserved' => true, + 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected), + 'command_key' => $commandKey, 'verified_existing_committed_receipt_without_replay' => $verifyApproved, + 'supplier_selection_or_order' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + } + if ($submitPurchase) { + $protected = array_values(array_diff($tables, ['requisitions', 'approval_steps', 'requisition_commands', 'audit_logs', 'workflow_notices', 'budget_entries', 'replenishment_work'])); + $otherRequests = fn () => DB::table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson(); + $othersBefore = $otherRequests(); + $budgetBefore = DB::table('budget_entries')->orderBy('id')->get()->toJson(); + $workOutside = fn () => DB::table('replenishment_work')->whereNot(fn ($query) => $query->where('tenant_id', $proposal['tenant_id'])->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id))->orderBy('id')->get()->toJson(); + $workOutsideBefore = $workOutside(); + $beforeCount = Requisition::query()->count(); + $requester = $actor('requester'); + $check($requester->id === $submission['requester_id'], 'Requisitante diferente do autorizado.'); + $commandKey = (string) Str::uuid(); + app(RequisitionWorkflow::class)->submit($requester, $purchase, $commandKey, (int) $purchase->process_version); + $purchase->refresh(); + $check($purchase->status === 'pending' && $purchase->steps()->count() > 0 && $purchase->steps()->where('decision', '!=', 'pending')->count() === 0, 'As decisões têm de permanecer pendentes.'); + $protectedAfter = $fingerprint($db, $protected); + $changed = array_keys(array_diff_assoc($protectedAfter, array_intersect_key($baseline, array_flip($protected)))); + $check(Requisition::query()->count() === $beforeCount && $otherRequests() === $othersBefore && $changed === [], + 'Alteração fora da submissão autorizada; cancelar. Tabelas: '.implode(', ', $changed)); + $check(DB::table('budget_entries')->where('requisition_id', '!=', $purchase->id)->orderBy('id')->get()->toJson() === $budgetBefore + && $workOutside() === $workOutsideBefore, 'Movimentos de outro processo alterados.'); + $reservation = DB::table('budget_entries')->where('requisition_id', $purchase->id)->sole(); + $check($reservation->kind === 'reserve' && (int) $reservation->created_by === $requester->id && (int) $reservation->committed_minor === 0 + && (int) $reservation->reserved_minor > 0 && (int) $reservation->reserved_minor <= $purchase->total_minor, 'Reserva orçamental incompatível com a submissão.'); + $routing = app(ApprovalRouting::class)->current($purchase, $requester); + $check($routing['status'] === 'available' && array_column($routing['people'], 'id') === [10], 'Encaminhamento diferente do ensaiado.'); + + return ['result' => 'submitted', 'submitted_at' => now()->toIso8601String(), 'environment' => $submission['environment'], + 'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-submission-proposal.json')), 'purchase_id' => $purchase->id, + 'purchase_number' => $purchase->number, 'status' => $purchase->status, 'quantity' => $purchase->items()->sole()->quantity, + 'total_minor_mzn' => $purchase->total_minor, 'current_role' => $routing['role'], 'eligible_people' => $routing['people'], + 'all_approval_steps_pending' => true, 'command_key' => $commandKey, 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), + 'budget_reserved_minor' => (int) $reservation->reserved_minor, 'budget_committed_minor' => 0, 'other_budget_entries_and_replenishment_scopes_preserved' => true, + 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected), 'other_requisitions_preserved' => true, + 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + } + if ($installCommands) { + $migration = 'database/migrations/2026_11_01_000046_add_requisition_command_versions.php'; + $protected = array_values(array_diff($tables, ['requisitions', 'migrations'])); + $requisitionProjection = fn () => array_column($db->select("select (to_jsonb(t) - 'process_version')::text as row from requisitions t order by (to_jsonb(t) - 'process_version')::text"), 'row'); + $beforeRequisitions = $requisitionProjection(); + $check(Artisan::call('migrate', ['--database' => 'replenishment_apply', '--path' => $migration, '--force' => true]) === 0, 'Actualização técnica não concluída.'); + $check(Schema::hasTable('requisition_commands') && Schema::hasColumn('requisitions', 'process_version') && DB::table('requisition_commands')->count() === 0, 'Estrutura de comandos inesperada.'); + $check($requisitionProjection() === $beforeRequisitions && DB::table('requisitions')->where('process_version', '!=', 1)->count() === 0 + && $fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'A instalação alterou registos existentes; cancelar.'); + $check((int) $db->selectOne("select count(*) as total from pg_trigger where tgrelid='requisition_commands'::regclass and not tgisinternal and tgname in ('rq_command_update','rq_command_delete')")->total === 2, 'Protecções de imutabilidade não instaladas.'); + + return ['result' => 'installed', 'installed_at' => now()->toIso8601String(), 'environment' => $proposal['environment'], 'migration' => $migration, + 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected), + 'existing_requisitions_preserved_except_initial_process_version' => true, 'command_receipts' => 0, 'immutability_triggers' => 2, + 'business_commands' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + } + $envelope = fn (array $data, string $action = 'save', ?SupplyRecord $old = null) => ['operation_key' => (string) Str::uuid(), 'record_key' => $old?->record_key, + 'version' => $old?->version ?? 0, 'action' => $action, 'reason' => 'Proposta de reposição sintética SGC8005 autorizada expressamente pelo proprietário neste chat em 02/11/2026.', 'data' => $data]; + $count = Requisition::query()->count(); + app(RequisitionCatalogue::class)->save($actor('catalogue'), $stock->id, [...$proposal['catalogue'], 'version' => 0, + 'stock_basis' => app(RequisitionCatalogue::class)->basis($stock), 'reason' => 'Publicação sintética ART-TEST expressamente autorizada pelo proprietário para o piloto local.']); + $policy = $planning->save($actor('prepare'), 'policy', $envelope($proposal['policy'])); + $policy = $planning->save($actor('review'), 'policy', $envelope([], 'approve', $policy)); + $automatic = app(AutomaticPurchases::class); + $rule = $automatic->save($actor('prepare'), $envelope([...$proposal['rule'], 'policy_key' => $policy->record_key])); + $review = $actor('review'); + $rule = $automatic->save($review, $envelope([], 'approve', $rule)); + $check(Requisition::query()->count() === $count + 1, 'Esperado exactamente um novo rascunho.'); + $purchase = Requisition::query()->latest('id')->firstOrFail(); + $check($purchase->status === 'draft' && $purchase->requester_id === $proposal['actors']['requester'] && $purchase->steps()->count() === 0 + && $purchase->items()->sole()->quantity === $proposal['expected']['quantity'] && $purchase->subtotal_minor === 2000 && $purchase->tax_minor === 320 && $purchase->total_minor === 2320, 'Rascunho diferente da proposta.'); + $check($fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'Alteração fora do âmbito autorizado; cancelar aplicação.'); + $check(collect(app(WorkflowTaskInbox::class)->build($actor('requester'), 'procurement'))->contains('id', 'purchase:'.$purchase->id.':draft'), 'Sem encaminhamento ao requisitante.'); + + return ['result' => 'applied', 'applied_at' => now()->toIso8601String(), 'proposal_sha256' => $hash, 'environment' => $proposal['environment'], + 'policy_id' => $policy->id, 'rule_id' => $rule->id, 'purchase_id' => $purchase->id, 'purchase_number' => $purchase->number, + 'quantity' => $purchase->items()->sole()->quantity, 'total_minor_mzn' => $purchase->total_minor, 'purchase_status' => $purchase->status, + 'physical_milli_after' => $balance['physical_milli'], 'human_submission_and_approvals_pending' => true, 'protected_tables_checked' => count($protected), + 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), + 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + }); +} catch (Throwable $error) { + $failure = 'Aplicação não concluída: '.get_class($error).($error instanceof LogicException ? ' '.$error->getMessage() : ''); + $failure .= ' '.json_encode(array_map(fn ($frame) => ['file' => $frame['file'] ?? null, 'line' => $frame['line'] ?? null], array_slice($error->getTrace(), 0, 8)), JSON_THROW_ON_ERROR); +} finally { + DB::purge('replenishment_restore'); + if ($restore !== null && preg_match('/^compras_pilot_restore_[a-f0-9]{12}$/D', $restore)) { + $admin->statement('DROP DATABASE IF EXISTS "'.$restore.'" WITH (FORCE)'); + } +} +if ($failure !== null) { + fwrite(STDERR, $failure.PHP_EOL); + exit(1); +} +if ($apply) { + $result['disposable_restore_removed'] = true; + $proofName = $evaluationScenario ? 'pilot-evaluation-activation' : ($marketScenario ? 'pilot-market-activation' : ($approvePurchase ? 'pilot-approval-activation' : ($submitPurchase ? 'pilot-submission-activation' : ($installCommands ? 'pilot-command-receipts-installation' : 'pilot-replenishment-activation')))); + file_put_contents($root.'/docs/proofs/'.$proofName.'.json', json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL); +} +echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/approved-attention-http.mjs b/scripts/approved-attention-http.mjs new file mode 100644 index 0000000..0775e9f --- /dev/null +++ b/scripts/approved-attention-http.mjs @@ -0,0 +1,27 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['procurement',200],['finance',200],['warehouse',403]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const baseReport=await viewer.request('/relatorios');assert.equal(baseReport.status,expected); + for(const [attention,fact] of [['approved_without_quotes','without_registered_quotes'],['approved_without_order','without_visible_order']]) { + const result=await viewer.request('/relatorios?attention='+attention);assert.equal(result.status,expected); + if(expected===200) { + const props=result.page.props; + assert.equal(result.page.encryptHistory,true);assert.equal(props.filters.attention,attention); + assert.equal(props.records.total,baseReport.page.props.insights.approved[fact]); + assert.ok(props.records.data.every(row=>row.status==='approved'&&(attention==='approved_without_quotes'?row.quotations_count===0:row.order_number===null))); + assert.equal((await viewer.request('/relatorios/compras.csv?attention='+attention,'GET',undefined,false)).status,200); + } + records.push({role,attention,status:result.status}); + } + } + await fs.writeFile('docs/proofs/approved-attention-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing pilot facts and authorised CSV; populated overlap, registered expired proposals and pagination tested in disposable databases.'},null,2)); + console.log('Consultas de aprovadas conferidas sem novas decisões no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/audit-pagination-http.mjs b/scripts/audit-pagination-http.mjs new file mode 100644 index 0000000..b7ea630 --- /dev/null +++ b/scripts/audit-pagination-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 =')) + .replace('async function request(path, method = \'GET\', data, inertia = true)', 'async function request(path, method = \'GET\', data, inertia = true, extraHeaders = {})') + .replace('headers: {', 'headers: { ...extraHeaders,'); +assert.ok(common.includes('extraHeaders = {}')); +await new Function('fs','assert','return (async()=>{'+common+` + const records=[]; + for(const role of ['admin','procurement','finance','warehouse']) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const result=await viewer.request('/requisicoes/2'); + if(result.status===200) { + assert.equal(result.page.encryptHistory,true); + assert.ok(result.page.props.audit.length<=25); + assert.ok(result.page.props.auditPagination.total>=result.page.props.audit.length); + const partial=await viewer.request('/requisicoes/2?audit_page=1','GET',undefined,true,{'X-Inertia-Partial-Component':'requisitions/show','X-Inertia-Partial-Data':'audit,auditPagination'}); + assert.equal(partial.status,200); + assert.ok(Array.isArray(partial.page.props.audit)); + assert.ok(partial.page.props.auditPagination); + assert.equal(partial.page.props.requisition,undefined); + assert.equal((await viewer.request('/requisicoes/2?audit_page=0')).status,422); + assert.equal((await viewer.request('/requisicoes/2/historico.csv','GET',undefined,false)).status,200); + } else assert.equal(result.status,404); + if(role==='admin') assert.equal(result.status,200); + records.push({role,status:result.status}); + } + await fs.writeFile('docs/proofs/audit-pagination-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing synthetic requisition 2: scoped detail, partial audit pagination and full CSV access.'},null,2)); + console.log('Paginação parcial do histórico conferida sem comandos de negócio no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/coordination-export-http.mjs b/scripts/coordination-export-http.mjs new file mode 100644 index 0000000..ffad326 --- /dev/null +++ b/scripts/coordination-export-http.mjs @@ -0,0 +1,26 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403],['procurement',403],['finance',403]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const view of ['reservations','deliveries','alternatives']) { + const result=await viewer.request('/compras/fornecimento.csv?view='+view+'&record_search=TEST'); + assert.equal(result.status,expected); + if(expected===200) { + assert.ok(result.text.includes('Acompanhamento de fornecimentos')); + assert.ok(result.text.includes('Designação preservada')); + assert.equal((await viewer.request('/compras/fornecimento.csv?view='+view+'&record_state=unknown')).status,422); + } + records.push({role,view,status:result.status}); + } + } + await fs.writeFile('docs/proofs/coordination-export-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing pilot export access; populated exports and native quantities tested in disposable databases.'},null,2)); + console.log('Exportação conferida sem comandos de negócio no piloto.'); +` + '})()')(fs, assert); diff --git a/scripts/coordination-filters-http.mjs b/scripts/coordination-filters-http.mjs new file mode 100644 index 0000000..2c5f3dd --- /dev/null +++ b/scripts/coordination-filters-http.mjs @@ -0,0 +1,28 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403],['procurement',403],['finance',403]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const view of ['reservations','deliveries','alternatives']) { + const result=await viewer.request('/compras/fornecimento?view='+view+'&record_search=TEST'); + assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true); + assert.equal(result.page.props.canPrepare,false); + assert.equal(result.page.props.recordFilters.record_search,'TEST'); + assert.equal((await viewer.request('/compras/fornecimento?view='+view+'&record_search='+'A'.repeat(101))).status,422); + assert.equal((await viewer.request('/compras/fornecimento?view='+view+'&record_state=unknown')).status,422); + } + records.push({role,view,status:result.status}); + } + } + await fs.writeFile('docs/proofs/coordination-filters-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing pilot access and validation; positive record filtering verified in disposable databases.'},null,2)); + console.log('Consulta filtrada conferida no piloto sem comandos de negócio.'); +` + '})()')(fs, assert); diff --git a/scripts/coordination-task-integration-http.mjs b/scripts/coordination-task-integration-http.mjs new file mode 100644 index 0000000..79bba36 --- /dev/null +++ b/scripts/coordination-task-integration-http.mjs @@ -0,0 +1,31 @@ +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 records=[]; + for(const role of ['admin','warehouse','procurement','finance']) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const pages=[]; + for(const view of ['reservations','deliveries','alternatives']) { + const result=await viewer.request('/compras/fornecimento?view='+view); + assert.ok([200,403].includes(result.status));if(role==='admin') assert.equal(result.status,200); + if(result.status===200) { + assert.equal(result.page.encryptHistory,true); + assert.equal(result.page.component,'supply/coordination'); + assert.ok(result.page.props.recordPagination);assert.ok(result.page.props.records.length<=25); + if(role==='admin') assert.equal(result.page.props.canPrepare,false); + } + pages.push({view,status:result.status,can_prepare:result.page?.props?.canPrepare??null}); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=supply');assert.equal(tasks.status,200); + const followups=tasks.page.props.tasks.data.filter(r=>r.task==='reservation_followup'||r.task==='delivery_followup'); + records.push({role,pages,first_page_followup_tasks:followups.length}); + } + await fs.writeFile('docs/proofs/coordination-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Read-only pilot access; partial/full receipts, allocations and corrections exercised in disposable databases.'},null,2)); + console.log('Acompanhamento e paginação conferidos sem criar previsões, reservas ou movimentos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/correction-source-http.mjs b/scripts/correction-source-http.mjs new file mode 100644 index 0000000..cc0de58 --- /dev/null +++ b/scripts/correction-source-http.mjs @@ -0,0 +1,25 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',200],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const path of ['/stock/ocorrencias','/stock/estornos']) { + const result=await viewer.request(path+'?source_search=TEST&source_form_key=prepare:new');assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true);assert.equal(result.page.props.capabilities.prepare,false); + assert.deepEqual(result.page.props.receipts,[]);if(path==='/stock/estornos') assert.deepEqual(result.page.props.operations,[]); + assert.equal((await viewer.request(path+'?source_search='+ 'A'.repeat(101))).status,422); + } + records.push({role,path,status:result.status}); + } + } + await fs.writeFile('docs/proofs/correction-source-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Existing pilot read rights; positive old origins and native reversals tested in disposable databases.'},null,2)); + console.log('Pesquisa de origens de correcção conferida sem decisões, concessões ou movimentos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/history-source-http.mjs b/scripts/history-source-http.mjs new file mode 100644 index 0000000..3ced485 --- /dev/null +++ b/scripts/history-source-http.mjs @@ -0,0 +1,21 @@ +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 records=[]; + for(const role of ['admin','procurement','finance']) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const page=await viewer.request('/compras/fornecimento?view=calendar&history_stock_item_id=1&history_warehouse_id=1&history_purpose=internal&history_period_from=2026-10-01&history_period_until=2026-10-31&history_form_key=prepare:new'); + assert.equal(page.status,200);assert.equal(page.page.encryptHistory,true);assert.equal(page.page.props.historyFormKey,'prepare:new'); + if(!page.page.props.canPrepare) assert.deepEqual(page.page.props.histories,[]); + assert.equal((await viewer.request('/compras/fornecimento?view=calendar&history_period_from=2026-10-01')).status,422); + records.push({role,can_prepare:page.page.props.canPrepare,source_choices:page.page.props.histories.length}); + } + await fs.writeFile('docs/proofs/history-source-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Existing pilot scope and lookup validation; approved historical periods and suspensions tested in disposable databases.'},null,2)); + console.log('Pesquisa de análises conferida sem criar origens, decisões ou acessos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/history-task-integration-http.mjs b/scripts/history-task-integration-http.mjs new file mode 100644 index 0000000..58e8ac8 --- /dev/null +++ b/scripts/history-task-integration-http.mjs @@ -0,0 +1,31 @@ +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, expected] of [['admin',200],['procurement',403],['finance',403]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const history=await viewer.request('/compras/fornecimento?view=history'); assert.equal(history.status,expected); + if (expected===200) { + assert.equal(history.page.component,'supply/history'); assert.equal(history.page.encryptHistory,true); + assert.equal(history.page.props.canPrepare,false); assert.equal(history.page.props.canReview,false); assert.equal(history.page.props.records.length,0); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=supply'); assert.equal(tasks.status,200); + const historical=tasks.page.props.tasks.data.filter(row=>row.task==='history_review'); assert.equal(historical.length,0); + routes.push({role,history_status:history.status,history_tasks:historical.length}); + if (role==='admin') { + const pilot=await viewer.request('/stock/piloto'); assert.equal(pilot.status,200); + const r=pilot.page.props.report; assert.equal(r.tables.requisition_commands,true); assert.equal(r.tables.requisition_process_version,true); + assert.equal(r.checks.find(row=>row.key==='installation').ok,true); + assert.equal(r.checks.find(row=>row.key==='catalogue').ok,true); assert.equal(r.checks.find(row=>row.key==='replenishment').ok,true); + routes.push({role,pilot_installation:true,warehouse_scenario_ready:r.warehouse_scenario_ready,replenishment_scenario_ready:r.replenishment_scenario_ready,client_configuration_complete:r.client_configuration_complete}); + } + } + await fs.writeFile('docs/proofs/history-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),result:'passed',environment:base,routes, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Existing current scope has no historical analysis and no historical execution grants; pending historical reviews tested only in disposable databases.'},null,2)); + console.log('Historico, competencias especificas, tarefas e diagnostico da instalacao conferidos.'); +` + '})()')(fs, assert); diff --git a/scripts/install-receipt-documents-pilot.php b/scripts/install-receipt-documents-pilot.php new file mode 100644 index 0000000..ab2ec98 --- /dev/null +++ b/scripts/install-receipt-documents-pilot.php @@ -0,0 +1,63 @@ +make(Kernel::class)->bootstrap(); +$owner = DB::connection('pgsql')->getConfig(); unset($owner['name']); +if ($owner['host'] !== '127.0.0.1') { throw new LogicException('Local server required.'); } +$qa = [...$owner, 'url'=>null, 'database'=>$s['DB_DATABASE'], 'username'=>$s['DB_USERNAME'], 'password'=>$s['DB_PASSWORD']]; +config(['database.connections.receipt_install'=>$qa, 'database.connections.receipt_owner'=>[...$owner,'url'=>null,'database'=>'postgres']]); +DB::setDefaultConnection('receipt_install'); +$db = DB::connection(); $admin = DB::connection('receipt_owner'); +$identity = $db->selectOne('select current_database() as db, current_user as role'); +if ($identity->db !== $s['DB_DATABASE'] || $identity->role !== $s['DB_USERNAME']) { throw new LogicException('Identity mismatch.'); } +$proofPath = $root.'/docs/proofs/receipt-block-installation.json'; +if (file_exists($proofPath)) { throw new LogicException('Installation proof exists; inspect before repeating.'); } +$tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"),'tablename'); +if (in_array('receipt_documents',$tables,true)) { throw new LogicException('Document table already exists.'); } +$fingerprint = function($connection,$tables) { $out=[]; foreach($tables as $table) { $rows=$connection->select('select row_to_json(t)::text as row from "'.str_replace('"','""',$table).'" t order by row_to_json(t)::text'); $out[$table]=hash('sha256',json_encode(array_column($rows,'row'),JSON_THROW_ON_ERROR)); } return $out; }; +$pg = function($binary,$args,$connection) { $p=new Process(['C:/Program Files/PostgreSQL/17/bin/'.$binary.'.exe',...$args],null,['PGHOST'=>$connection['host'],'PGPORT'=>(string)$connection['port'],'PGUSER'=>$connection['username'],'PGPASSWORD'=>$connection['password']],timeout:120); $p->run(); if(!$p->isSuccessful()) { throw new RuntimeException('Backup/restore failed.'); } }; +$restore='compras_pilot_restore_'.bin2hex(random_bytes(6)); $created=false; +try { + $db->beginTransaction(); $db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $baseline=$fingerprint($db,$tables); + $directory='C:/laragon/data/sgf-tempo/backups/compras-receipt-documents-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)); + if(!mkdir($directory,0700,true)) { throw new RuntimeException('Backup directory unavailable.'); } + $dump=$directory.'/before.dump'; + $snapshot=$db->selectOne('select pg_export_snapshot() as snapshot')->snapshot; + $pg('pg_dump',['--format=custom','--snapshot='.$snapshot,'--file='.$dump,'--dbname='.$qa['database']],$qa); + $db->commit(); + $admin->statement('CREATE DATABASE "'.$restore.'"'); $created=true; + $pg('pg_restore',['--no-owner','--no-privileges','--exit-on-error','--dbname='.$restore,$dump],$owner); + config(['database.connections.receipt_restore'=>[...$owner,'url'=>null,'database'=>$restore]]); + if($fingerprint(DB::connection('receipt_restore'),$tables)!==$baseline) { throw new LogicException('Restored backup differs.'); } + $disk=Storage::disk('receipt_documents'); $probe='.installation-'.bin2hex(random_bytes(8)); + try { $disk->put($probe,'private-storage-probe'); if($disk->get($probe)!=='private-storage-probe') { throw new RuntimeException('Storage unavailable.'); } } finally { $disk->delete($probe); } + $migration='database/migrations/2026_11_03_000002_create_receipt_documents.php'; + $db->transaction(function() use($db,$tables,$baseline,$fingerprint,$migration) { + foreach($tables as $table) { $db->statement('LOCK TABLE "'.str_replace('"','""',$table).'" IN SHARE MODE'); } + if($fingerprint($db,$tables)!==$baseline) { throw new LogicException('Pilot changed after backup; abort.'); } + if(Artisan::call('migrate',['--database'=>'receipt_install','--path'=>$migration,'--force'=>true])!==0) { throw new RuntimeException('Target migration failed.'); } + $protected=array_values(array_diff($tables,['migrations'])); + if($fingerprint($db,$protected)!==array_intersect_key($baseline,array_flip($protected))) { throw new LogicException('Business data changed.'); } + if($db->table('receipt_documents')->count()!==0 || $db->table('migrations')->where('migration','2026_11_03_000001_add_two_factor_columns_to_users')->exists()) { throw new LogicException('Unexpected installation state.'); } + }); + $proof=['checked_at'=>gmdate('c'),'environment'=>'http://127.0.0.1:8005','result'=>'passed','authorization'=>'User explicitly authorized receipt document block installation','migration'=>$migration,'backup'=>$dump,'backup_sha256'=>hash_file('sha256',$dump),'restored_tables_checked'=>count($tables),'protected_tables_checked'=>count($tables)-1,'business_data_unchanged'=>true,'documents_created'=>0,'mfa_enabled'=>false,'restore_database_removed'=>false]; +} finally { + if($db->transactionLevel()>0) { $db->rollBack(); } + DB::purge('receipt_restore'); + if($created) { $admin->statement('DROP DATABASE "'.$restore.'" WITH (FORCE)'); } +} +$proof['restore_database_removed']=true; +file_put_contents($proofPath,json_encode($proof,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES).PHP_EOL); +echo 'Receipt documents installed; backup restored and business data preserved.'.PHP_EOL; diff --git a/scripts/inventory-pagination-http.mjs b/scripts/inventory-pagination-http.mjs new file mode 100644 index 0000000..d6ae932 --- /dev/null +++ b/scripts/inventory-pagination-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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',200],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const path of ['/stock/ocorrencias','/stock/estornos']) { + const result=await viewer.request(path);assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true);assert.equal(result.page.props.recordPagination.total,0); + assert.deepEqual(result.page.props.records,[]);assert.deepEqual(result.page.props.filters,{search:'',state:''}); + assert.equal(result.page.props.capabilities.prepare,false);assert.equal(result.page.props.capabilities.review,false); + assert.equal(result.page.props.capabilities.execute,role==='warehouse'); + const filtered=await viewer.request(path+'?search=TEST&state=approved');assert.equal(filtered.status,200); + assert.equal(filtered.page.props.recordPagination.total,0); + assert.equal((await viewer.request(path+'?records_page=0')).status,422); + assert.equal((await viewer.request(path+'?state=unknown')).status,422); + } + records.push({role,path,status:result.status}); + } + } + await fs.writeFile('docs/proofs/inventory-pagination-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Pilot access and empty paginated/filtered lists; pending processes, native corrections and old sources tested in disposable databases.'},null,2)); + console.log('Ocorrências e estornos paginados conferidos sem decisões ou movimentos novos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/inventory-task-integration-http.mjs b/scripts/inventory-task-integration-http.mjs new file mode 100644 index 0000000..aeebca6 --- /dev/null +++ b/scripts/inventory-task-integration-http.mjs @@ -0,0 +1,27 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',200],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const [path,component] of [['/stock/ocorrencias','stock/dispositions'],['/stock/estornos','stock/reversals']]) { + const response=await viewer.request(path);assert.equal(response.status,expected); + if(expected===200) { + assert.equal(response.page.component,component);assert.equal(response.page.encryptHistory,true);assert.equal(response.page.props.records.length,0); + assert.equal(response.page.props.capabilities.prepare,false);assert.equal(response.page.props.capabilities.review,false); + assert.equal(response.page.props.capabilities.execute,role==='warehouse'); + } + records.push({role,path,status:response.status}); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=warehouse');assert.equal(tasks.status,200); + assert.equal(tasks.page.props.tasks.data.filter(row=>row.task.startsWith('disposition_')||row.task.startsWith('reversal_')).length,0); + } + await fs.writeFile('docs/proofs/inventory-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'No persistent stock dispositions or reversals exist; pending decisions and physical movements tested in disposable databases only.'},null,2)); + console.log('Ocorrencias, estornos e tarefas conferidos nos perfis existentes.'); +` + '})()')(fs, assert); diff --git a/scripts/lot-pagination-http.mjs b/scripts/lot-pagination-http.mjs new file mode 100644 index 0000000..fc1f454 --- /dev/null +++ b/scripts/lot-pagination-http.mjs @@ -0,0 +1,25 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const result=await viewer.request('/stock/lotes');assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true); + assert.equal(result.page.props.controlPagination.total,0);assert.equal(result.page.props.historyPagination.total,0); + assert.deepEqual(result.page.props.records,[]);assert.deepEqual(result.page.props.history,[]); + assert.equal((await viewer.request('/stock/lotes?controls_page=0')).status,422); + assert.equal((await viewer.request('/stock/lotes?history_page=-1')).status,422); + } + records.push({role,status:result.status}); + } + await fs.writeFile('docs/proofs/lot-pagination-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Read-only pilot access and empty pagination; 210 controls/history and pending-active pairs tested in disposable databases.'},null,2)); + console.log('Paginação de lotes conferida no piloto, sem novos dados ou permissões.'); +`+'})()')(fs,assert); diff --git a/scripts/lot-quality-task-integration-http.mjs b/scripts/lot-quality-task-integration-http.mjs new file mode 100644 index 0000000..a2a8795 --- /dev/null +++ b/scripts/lot-quality-task-integration-http.mjs @@ -0,0 +1,26 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const page=await viewer.request('/stock/lotes');assert.equal(page.status,expected); + if(expected===200) { + assert.equal(page.page.encryptHistory,true);assert.equal(page.page.props.records.length,0); + for(const action of ['prepare','review','release','hold']) assert.equal(page.page.props.capabilities[action],false); + assert.equal((await viewer.request('/stock/lotes?lot_id=999999&warehouse_id=1')).status,422); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=warehouse');assert.equal(tasks.status,200); + const quality=tasks.page.props.tasks.data.filter(row=>row.task==='lot_identify'||row.task==='lot_release'||row.task==='lot_hold'); + assert.equal(quality.length,0); + records.push({role,status:page.status,quality_tasks:quality.length}); + } + await fs.writeFile('docs/proofs/lot-quality-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Pilot has no active lot controls or operational quality grants; pending decisions and lot links exercised in disposable databases.'},null,2)); + console.log('Consulta e restrições de qualidade conferidas sem alterar lotes, decisões ou direitos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/lot-task-integration-http.mjs b/scripts/lot-task-integration-http.mjs new file mode 100644 index 0000000..37fd4d5 --- /dev/null +++ b/scripts/lot-task-integration-http.mjs @@ -0,0 +1,24 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const response=await viewer.request('/stock/lotes');assert.equal(response.status,expected); + if(expected===200) { + assert.equal(response.page.component,'stock/lots');assert.equal(response.page.encryptHistory,true);assert.equal(response.page.props.records.length,0); + assert.equal(response.page.props.capabilities.prepare,false);assert.equal(response.page.props.capabilities.review,false); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=warehouse');assert.equal(tasks.status,200); + assert.equal(tasks.page.props.tasks.data.filter(row=>row.task==='lot_setup_activate').length,0); + records.push({role,status:response.status,lot_setup_tasks:0}); + } + await fs.writeFile('docs/proofs/lot-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Live pilot has no lot setups or operational lot grants; pending configuration review tested only in disposable databases.'},null,2)); + console.log('Controlo por lotes e tarefas conferidos sem novas concessoes.'); +` + '})()')(fs, assert); diff --git a/scripts/mfa-foundation-http.mjs b/scripts/mfa-foundation-http.mjs new file mode 100644 index 0000000..c2870f8 --- /dev/null +++ b/scripts/mfa-foundation-http.mjs @@ -0,0 +1,19 @@ +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 records=[]; + for(const role of ['admin','warehouse']) { + const viewer=client(); await viewer.enter(role); + const result=await viewer.request('/user/confirm-password'); + assert.equal(result.status,200); + assert.equal(result.page.component,'auth/confirm-password'); + assert.equal(result.page.encryptHistory,true); + assert.equal((await viewer.request('/two-factor-challenge')).status,404); + records.push({role,passwordConfirmation:result.status,twoFactorDisabled:true}); + } + await fs.writeFile('docs/proofs/mfa-foundation-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + enrollment_commands:false,password_changes:false,accounts_or_grants_changed:false,pilot_migration_applied:false,sgf_changed:false,visual_review:false},null,2)); + console.log('Base de confirmação conferida; MFA do piloto continua desactivado.'); +`+'})()')(fs,assert); diff --git a/scripts/mfa-management-http.mjs b/scripts/mfa-management-http.mjs new file mode 100644 index 0000000..f196c6c --- /dev/null +++ b/scripts/mfa-management-http.mjs @@ -0,0 +1,24 @@ +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 records=[]; + for(const role of ['admin','warehouse']) { + const viewer=client(); await viewer.enter(role); + const result=await viewer.request('/settings/security'); + assert.equal(result.status,200); + assert.equal(result.page.component,'settings/security'); + assert.equal(result.page.encryptHistory,true); + assert.equal(result.page.props.canManageTwoFactor,undefined); + assert.equal(result.page.props.secretKey,undefined); + assert.equal(result.page.props.recoveryCodes,undefined); + assert.equal((await viewer.request('/user/two-factor-secret-key')).status,404); + assert.equal((await viewer.request('/user/two-factor-recovery-codes')).status,404); + records.push({role,status:result.status,mfaDisabled:true}); + } + await fs.writeFile('docs/proofs/mfa-management-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + enrollment_commands:false,password_changes:false,accounts_or_grants_changed:false,pilot_migration_applied:false,sgf_changed:false,visual_review:false, + coverage:'Pilot remains disabled; enabled native management flows tested in disposable databases.'},null,2)); + console.log('Interface de segurança conferida; adesão MFA do piloto permanece desactivada.'); +`+'})()')(fs,assert); diff --git a/scripts/needed-attention-http.mjs b/scripts/needed-attention-http.mjs new file mode 100644 index 0000000..8575469 --- /dev/null +++ b/scripts/needed-attention-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 records=[]; + for(const [role,expected] of [['admin',200],['procurement',200],['finance',200],['warehouse',403]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const baseReport=await viewer.request('/relatorios'); + assert.equal(baseReport.status,expected); + const report=await viewer.request('/relatorios?attention=unapproved_needed'); + assert.equal(report.status,expected); + if(expected===200) { + assert.equal(report.page.encryptHistory,true); + const props=report.page.props; + assert.equal(props.filters.attention,'unapproved_needed'); + assert.equal(props.records.total,baseReport.page.props.insights.unapproved_past_needed_date); + assert.ok(props.records.data.every(row=>['draft','pending'].includes(row.status))); + const csv=await viewer.request('/relatorios/compras.csv?attention=unapproved_needed','GET',undefined,false); + assert.equal(csv.status,200);assert.ok(csv.text.includes('Data de necessidade')); + assert.equal((await viewer.request('/relatorios?attention=unknown')).status,422); + } + records.push({role,status:report.status}); + } + await fs.writeFile('docs/proofs/needed-attention-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing pilot report scopes; populated dates, pagination and revoked streaming access tested in disposable databases.'},null,2)); + console.log('Filtro de necessidades por aprovar conferido em leitura no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/notice-consultation-http.mjs b/scripts/notice-consultation-http.mjs new file mode 100644 index 0000000..c6398b4 --- /dev/null +++ b/scripts/notice-consultation-http.mjs @@ -0,0 +1,26 @@ +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 records=[]; + for(const role of ['admin','warehouse','procurement','finance']) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const view of ['all','unread','tasks','outcomes']) { + const result=await viewer.request('/requisicoes/avisos?view='+view+'&search=TEST&page=999'); + assert.equal(result.status,200); + assert.equal(result.page.encryptHistory,true); + assert.equal(result.page.props.search,'TEST'); + assert.equal(result.page.props.view,view); + assert.ok(result.page.props.notices.current_page>=1); + records.push({role,view,status:result.status,total:result.page.props.notices.total}); + } + assert.equal((await viewer.request('/requisicoes/avisos?search='+'A'.repeat(101))).status,422); + } + await fs.writeFile('docs/proofs/notice-consultation-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + read_commands:false,business_decisions:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing synthetic profiles in read-only consultation; populated notice and mark-read flows tested in disposable databases.'},null,2)); + console.log('Consulta dos avisos conferida sem marcar avisos do piloto como lidos.'); +` + '})()')(fs, assert); diff --git a/scripts/pilot-approval-activation-http.mjs b/scripts/pilot-approval-activation-http.mjs new file mode 100644 index 0000000..131d7cb --- /dev/null +++ b/scripts/pilot-approval-activation-http.mjs @@ -0,0 +1,25 @@ +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 records=[]; + for(const role of ['department_head','procurement','requester']) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const detail=await viewer.request('/requisicoes/2');assert.equal(detail.status,200); + const r=detail.page.props.requisition;assert.equal(r.status,'approved');assert.equal(r.items[0].quantity,'2.000');assert.equal(r.total_minor,2320); + assert.equal(r.steps.length,1);assert.equal(r.steps[0].decision,'approved');assert.equal(r.steps[0].approver_id,10);assert.equal(detail.page.props.canDecide,false); + const tasks=await viewer.request('/requisicoes/tarefas');assert.equal(tasks.status,200); + assert.equal(tasks.page.props.tasks.data.filter(row=>row.id.startsWith('purchase:2:')).length,0); + if(role==='procurement') { + const task=tasks.page.props.tasks.data.find(row=>row.id==='quotation:2:prepare');assert.ok(task); + const market=await viewer.request(task.href);assert.equal(market.status,200);assert.equal(market.page.props.canCreate,true); + records.push({role,status:r.status,market_task:true,eligible_suppliers:market.page.props.suppliers.length}); + } else records.push({role,status:r.status,approval_task:false}); + } + await fs.writeFile('docs/proofs/pilot-approval-activation-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + quantity:'2.000',total_minor_mzn:2320,approved_by:10,business_commands:false,supplier_selection_or_order:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false},null,2)); + console.log('Aprovacao unica e encaminhamento para consulta ao mercado confirmados.'); +` + '})()')(fs, assert); diff --git a/scripts/pilot-evaluation-activation-http.mjs b/scripts/pilot-evaluation-activation-http.mjs new file mode 100644 index 0000000..d8aae21 --- /dev/null +++ b/scripts/pilot-evaluation-activation-http.mjs @@ -0,0 +1,29 @@ +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 records=[]; + 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 page=await viewer.request('/requisicoes/2/cotacoes');assert.equal(page.status,200); + assert.equal(page.page.props.ready,true); + const rows=page.page.props.quotations;assert.equal(rows.length,3); + for(const row of rows) { + assert.equal(row.evaluations.length,2); + assert.deepEqual(row.evaluations.map(e=>e.evaluator_id).sort((a,b)=>a-b),[10,12]); + for(const e of row.evaluations) for(const criterion of ['quality','history','payment']) assert.equal(Number(e[criterion]),5); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=purchases');assert.equal(tasks.status,200); + assert.equal(tasks.page.props.tasks.data.filter(row=>row.task==='quotation_evaluate'&&[2,3,4].some(id=>row.id==='quotation:'+id+':evaluate')).length,0); + const comparison=tasks.page.props.tasks.data.some(row=>row.id==='quotation:2:result'); + if(role==='procurement') assert.equal(comparison,true); + const detail=await viewer.request('/requisicoes/2');assert.equal(detail.status,200); + assert.equal(detail.page.props.requisition.status,'approved');assert.equal(detail.page.props.requisition.total_minor,2320); + records.push({role,evaluations:6,comparison_ready:true,comparison_task:comparison}); + } + await fs.writeFile('docs/proofs/pilot-evaluation-activation-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records,business_commands:false,supplier_selection_or_order:false,visual_review:false},null,2)); + console.log('Seis avaliações neutras e encaminhamento para comparação conferidos por HTTP.'); +` + '})()')(fs, assert); diff --git a/scripts/pilot-market-activation-http.mjs b/scripts/pilot-market-activation-http.mjs new file mode 100644 index 0000000..fe16d23 --- /dev/null +++ b/scripts/pilot-market-activation-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 =')); +const proof = JSON.parse(await fs.readFile('docs/proofs/pilot-market-activation.json', 'utf8')); +await new Function('fs', 'assert', 'proof', 'return (async()=>{' + common + ` + const records=[]; + 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 page=await viewer.request('/requisicoes/2/cotacoes');assert.equal(page.status,200); + const quotes=page.page.props.quotations;assert.ok(quotes); + const rows=Array.isArray(quotes)?quotes:quotes.data;assert.equal(rows.length,3); + assert.deepEqual(rows.map(row=>row.total_minor).sort((a,b)=>a-b),[2320,2552,2784]); + const tasks=await viewer.request('/requisicoes/tarefas?module=purchases');assert.equal(tasks.status,200); + const evaluate=tasks.page.props.tasks.data.filter(row=>row.task==='quotation_evaluate'&&proof.quotation_ids.some(id=>row.id==='quotation:'+id+':evaluate')); + const supplierTasks=tasks.page.props.tasks.data.filter(row=>row.task==='supplier_review'); + assert.equal(supplierTasks.length,0); + const detail=await viewer.request('/requisicoes/2');assert.equal(detail.status,200);assert.equal(detail.page.props.requisition.status,'approved');assert.equal(detail.page.props.requisition.total_minor,2320); + if(role==='finance') { + for(const id of [proof.supplier_ids.alternative_a,proof.supplier_ids.alternative_b]) { + const supplier=await viewer.request('/fornecedores/'+id);assert.equal(supplier.status,200); + assert.equal(supplier.page.props.supplier.status,'active');assert.equal(supplier.page.props.supplier.reviewed_by,12); + } + } + records.push({role,quotations:rows.length,evaluation_tasks:evaluate.length,pending_supplier_tasks:supplierTasks.length}); + } + await fs.writeFile('docs/proofs/pilot-market-activation-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + totals_minor_mzn:[2320,2552,2784],business_commands:false,supplier_selection_or_order:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false},null,2)); + console.log('Cadastros independentes, tres propostas e encaminhamento conferidos sem avaliar.'); +` + '})()')(fs, assert, proof); diff --git a/scripts/pilot-replenishment-activation-http.mjs b/scripts/pilot-replenishment-activation-http.mjs new file mode 100644 index 0000000..3bcca14 --- /dev/null +++ b/scripts/pilot-replenishment-activation-http.mjs @@ -0,0 +1,35 @@ +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 =')); +const proof = JSON.parse(await fs.readFile('docs/proofs/pilot-replenishment-activation.json', 'utf8')); +await new Function('fs', 'assert', 'proof', 'return (async()=>{' + common + ` + assert.equal(proof.result, 'applied'); + const requester = client(); await requester.enter('requester'); + assert.equal((await requester.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const tasks = await requester.request('/requisicoes/tarefas'); assert.equal(tasks.status,200); + const task = tasks.page.props.tasks.data.find(row => row.id === 'purchase:' + proof.purchase_id + ':draft'); assert.ok(task); + const page = await requester.request(task.href); assert.equal(page.status,200); + const r = page.page.props.requisition; + assert.equal(r.status,'draft'); assert.equal(r.items.length,1); assert.equal(r.items[0].quantity,'2.000'); assert.equal(r.total_minor,2320); + assert.equal(r.steps.length,0); assert.equal(page.page.props.canEdit,true); assert.ok(page.page.props.automaticSource); + const checks = []; + for (const [role, prepare, review] of [['procurement',true,false],['finance',false,true]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const response=await viewer.request('/stock/reposicao?rule_id='+proof.rule_id); + assert.equal(response.status,200); assert.equal(response.page.props.canPrepare,prepare); assert.equal(response.page.props.canReview,review); + assert.equal(response.page.props.rules.data.length,1); assert.equal(response.page.props.rules.data[0].state,'approved'); + checks.push({role,result:'passed'}); + } + const stock=client(); await stock.enter('warehouse'); + assert.equal((await stock.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const availability=await stock.request('/stock/disponibilidade?warehouse_id=1'); assert.equal(availability.status,200); + const row=availability.page.props.rows.data.find(row=>row.id===1); assert.equal(row.physical_milli,1000); assert.equal(row.free_milli,1000); + await fs.writeFile('docs/proofs/pilot-replenishment-activation-http.json',JSON.stringify({checked_at:new Date().toISOString(),result:'passed',environment:base, + purchase_id:r.id,purchase_number:r.number,status:r.status,quantity:r.items[0].quantity,total_minor_mzn:r.total_minor,requester_task:true, + automatic_source_reused:true,physical_milli:row.physical_milli,free_milli:row.free_milli,checks, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false},null,2)); + console.log('Rascunho, origem da reposicao, tarefas, competencias e saldo confirmados por HTTP.'); +` + '})()')(fs, assert, proof); diff --git a/scripts/pilot-replenishment-dry-run.php b/scripts/pilot-replenishment-dry-run.php new file mode 100644 index 0000000..a281e67 --- /dev/null +++ b/scripts/pilot-replenishment-dry-run.php @@ -0,0 +1,324 @@ +make(Kernel::class)->bootstrap(); +$owner = DB::connection('pgsql')->getConfig(); +unset($owner['name']); +if ($owner['host'] !== '127.0.0.1') { + throw new LogicException('Cópia descartável exclusivamente local.'); +} +$clone = 'compras_receipts_test_'.bin2hex(random_bytes(6)); +if ($clone === $settings['DB_DATABASE']) { + throw new LogicException('Destino descartável tem de ser distinto da origem.'); +} +config(['database.connections.pilot_clone_admin' => [...$owner, 'url' => null, 'database' => 'postgres']]); +$admin = DB::connection('pilot_clone_admin'); +$bin = 'C:/Program Files/PostgreSQL/17/bin/'; +// Resolve the installed binaries without embedding credentials in commands or logs. +if (! is_file($bin.'pg_dump.exe')) { + $candidates = glob('C:/laragon/bin/postgresql/*/bin/pg_dump.exe'); + if (count($candidates) !== 1) { + throw new LogicException('Instalação PostgreSQL não inequívoca.'); + } + $bin = dirname($candidates[0]).'/'; +} +$privateRoot = 'C:/laragon/data/sgf-tempo/proofs'; +if (! is_dir($privateRoot)) { + mkdir($privateRoot, 0700, true); +} +$dump = $privateRoot.'/replenishment-dry-run-'.bin2hex(random_bytes(6)).'.dump'; +$runPg = function (string $binary, array $arguments, array $connection) { + $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('Cópia/restauro descartável não concluído.'); + } +}; +$created = false; +$proof = null; +$failure = null; +try { + $qa = [...$owner, 'host' => $settings['DB_HOST'], 'port' => $settings['DB_PORT'] ?? '5432', 'database' => $settings['DB_DATABASE'], + 'username' => $settings['DB_USERNAME'], 'password' => $settings['DB_PASSWORD']]; + $runPg($bin.'pg_dump.exe', ['--format=custom', '--file='.$dump, '--dbname='.$qa['database']], $qa); + $admin->statement('CREATE DATABASE "'.$clone.'"'); + $created = true; + $runPg($bin.'pg_restore.exe', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$clone, $dump], $owner); + config(['database.connections.pilot_clone' => [...$owner, 'url' => null, 'database' => $clone], 'work_context.composition' => 'sgc', 'app.env' => 'testing']); + DB::setDefaultConnection('pilot_clone'); + $app->detectEnvironment(fn () => 'testing'); + // Reproduce only the existing evaluation entitlement fallback in this restored copy. + // It is not a persisted customer contract or a new permission grant. + $accessProposal = json_decode(file_get_contents($root.'/docs/proofs/pilot-access-proposal.json'), true, 64, JSON_THROW_ON_ERROR); + config(['work_context.entitlements.'.$proposal['tenant_id'] => $accessProposal['enabled_contexts']]); + if (DB::connection()->getDatabaseName() !== $clone || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $clone)) { + throw new LogicException('Comandos proibidos fora da cópia descartável.'); + } + $request = Request::create($proposal['environment'].'/stock/reposicao', 'GET', server: ['REMOTE_ADDR' => '127.0.0.1']); + $request->setLaravelSession($app['session']->driver('array')); + $app->instance('request', $request); + $request->setUserResolver(fn ($guard = null) => auth()->guard($guard)->user()); + $check = function (bool $value, string $message) { + if (! $value) { + throw new LogicException($message); + } + }; + $actor = function (string $task) use ($proposal, $request) { + $user = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($proposal['actors'][$task]); + auth()->guard('web')->setUser($user); + $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $user->id, 'tenant_id' => $user->tenant_id]); + + return $user; + }; + if (in_array('--evaluations', $argv, true)) { + $nextFile = $root.'/docs/proofs/pilot-evaluation-proposal.json'; + $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); + $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization', 'Proposta sintética de avaliação inválida.'); + $preparer = $actor('prepare'); + $purchase = Requisition::query()->findOrFail($next['purchase_id']); + $check($purchase->status === 'approved' && $purchase->number === $next['purchase_number'], 'Compra diferente da proposta.'); + $quotes = Quotation::query()->where('requisition_id', $purchase->id)->orderBy('id')->get(); + $check($quotes->pluck('id')->all() === $next['quotation_ids'] && $quotes->sum(fn ($quote) => $quote->evaluations()->count()) === 0, 'Propostas ou avaliações já alteradas.'); + $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'suppliers', 'quotations', 'quotation_items', 'purchase_orders', 'stock_movements', 'requisitions', 'requisition_items', 'budget_entries', 'purchase_awards']; + $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); + $baseline = $fingerprint(); + $duplicateRefused = false; + foreach ($next['evaluators'] as $id) { + $evaluator = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($id); + auth()->guard('web')->setUser($evaluator); + $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $evaluator->id, 'tenant_id' => $evaluator->tenant_id]); + foreach ($quotes as $quote) { + $input = Request::create($proposal['environment'].'/cotacoes/'.$quote->id.'/avaliar', 'POST', $next['grades_for_each_quotation_and_each_evaluator']); + $input->setUserResolver(fn () => $evaluator); + app(QuotationController::class)->evaluate($input, $quote, app(RequisitionWorkflow::class)); + if (! $duplicateRefused) { + try { + app(QuotationController::class)->evaluate($input, $quote, app(RequisitionWorkflow::class)); + } catch (ValidationException|HttpException) { + $duplicateRefused = true; + } + $check($duplicateRefused, 'Uma segunda avaliação da mesma pessoa não pode duplicar o registo.'); + } + } + } + $preparer = $actor('prepare'); + $comparison = app(ProposalComparison::class)->inspect($purchase); + $check($comparison['ready'] && $comparison['policy']['minimum_evaluators'] === $next['expected_minimum_qualified_evaluators_per_quotation'], 'Comparação não ficou pronta segundo a política existente.'); + $check($quotes->sum(fn ($quote) => $quote->evaluations()->count()) === $next['expected_evaluations'] && $fingerprint() === $baseline, 'Resultados fora do âmbito de avaliação.'); + $check(collect(app(WorkflowTaskInbox::class)->build($preparer, 'procurement'))->contains('id', 'quotation:'.$purchase->id.':result'), 'Comparação sem encaminhamento para conferência humana.'); + $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', + 'source_business_commands' => false, 'evaluations_in_copy' => 6, 'evaluators' => $next['evaluators'], 'duplicate_evaluation_refused' => $duplicateRefused, + 'comparison_ready' => true, 'human_comparison_review_task' => true, 'supplier_selection_award_or_order' => false, 'protected_tables_unchanged' => $protected, + 'outbound_communication' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + } elseif (in_array('--market', $argv, true)) { + $nextFile = $root.'/docs/proofs/pilot-market-proposal.json'; + $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); + $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization', 'Proposta sintética de consulta inválida.'); + require_once __DIR__.'/support/pilot-market-scenario.php'; + $scenario = pilotMarketScenario($app, $proposal, $next, $actor, $check); + $protected = $scenario['protected_tables']; + $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', + 'source_business_commands' => false, 'new_synthetic_suppliers_in_copy' => 2, 'supplier_preparation_and_review_independent' => true, 'quotations_in_copy' => 3, + 'totals_minor_mzn' => [2320, 2552, 2784], 'evaluations' => 0, 'supplier_selection_award_or_order' => false, 'protected_tables_unchanged' => $protected, + 'outbound_communication' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; + } elseif (in_array('--approval', $argv, true)) { + $nextFile = $root.'/docs/proofs/pilot-approval-proposal.json'; + $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); + $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization' && $next['action'] === 'approve', 'Proposta de aprovação inválida.'); + $user = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($next['approver_id']); + auth()->guard('web')->setUser($user); + $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $user->id, 'tenant_id' => $user->tenant_id]); + $purchase = Requisition::query()->findOrFail($next['purchase_id']); + $check($purchase->status === 'pending' && $purchase->number === $next['purchase_number'] && $purchase->canDecide($user) + && $purchase->items()->sole()->quantity === $next['quantity'] && $purchase->total_minor === $next['total_minor_mzn'], 'Compra ou competência diferente da proposta.'); + $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'purchase_orders', 'stock_movements', 'requisition_items', 'budget_entries']; + $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); + $baseline = $fingerprint(); + $count = Requisition::query()->count(); + $key = (string) Str::uuid(); + $workflow = app(RequisitionWorkflow::class); + $workflow->decide($user, $purchase, 'approved', $next['decision_comment'], $key); + $workflow->decide($user, $purchase->fresh(), 'approved', $next['decision_comment'], $key); + $purchase->refresh(); + $check($purchase->status === 'approved' && $purchase->steps()->count() === 1 && $purchase->steps()->sole()->approver_id === $user->id, 'Resultado diferente da etapa de aprovação prevista.'); + $check($fingerprint() === $baseline && Requisition::query()->count() === $count, 'A decisão alterou artigos, stock, contas ou ordens.'); + $reservation = DB::table('budget_entries')->where('requisition_id', $purchase->id)->sole(); + $check($reservation->kind === 'reserve' && (int) $reservation->reserved_minor === $next['total_minor_mzn'] && (int) $reservation->committed_minor === 0, 'A aprovação da requisição deve preservar a reserva existente, sem compromisso de encomenda.'); + $procurement = $actor('prepare'); + $tasks = app(WorkflowTaskInbox::class)->build($procurement, 'procurement'); + $check(collect($tasks)->contains('id', 'quotation:'.$purchase->id.':prepare'), 'Compra aprovada sem encaminhamento para consulta ao mercado.'); + $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', + 'database' => 'disposable restored copy of local synthetic SGC', 'source_business_commands' => false, 'purchase_id' => $purchase->id, + 'purchase_number' => $purchase->number, 'new_status_in_copy' => $purchase->status, 'approved_by_in_copy' => $user->id, + 'budget_reserved_minor' => (int) $reservation->reserved_minor, 'budget_committed_minor' => 0, 'market_consultation_task_for_procurement' => true, + 'same_command_repeated_without_duplication' => true, 'items_values_stock_orders_access_preserved' => true, 'sgf_installation_changed' => false]; + } elseif (in_array('--submission', $argv, true)) { + $nextFile = $root.'/docs/proofs/pilot-submission-proposal.json'; + $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); + $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization' && $next['action'] === 'submit', 'Proposta de submissão inválida.'); + $requester = $actor('requester'); + $purchase = Requisition::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($next['purchase_id']); + $check($purchase->number === $next['purchase_number'] && $purchase->requester_id === $next['requester_id'] && $purchase->status === 'draft' + && $purchase->items()->sole()->quantity === $next['quantity'] && $purchase->total_minor === $next['total_minor_mzn'] && $purchase->steps()->count() === 0, 'Rascunho diferente da proposta de submissão.'); + $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'purchase_orders', 'stock_movements']; + $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); + $baseline = $fingerprint(); + $count = Requisition::query()->count(); + $itemsBefore = $purchase->items()->get()->toArray(); + $migration = 'database/migrations/2026_11_01_000046_add_requisition_command_versions.php'; + $installedInCopy = false; + if (! Schema::hasTable('requisition_commands')) { + $check(! Schema::hasColumn('requisitions', 'process_version'), 'Instalação parcial inesperada.'); + $check(Artisan::call('migrate', ['--database' => 'pilot_clone', '--path' => $migration, '--force' => true]) === 0, 'Actualização de recibos de comandos falhou na cópia.'); + $installedInCopy = true; + $purchase->refresh(); + } + $key = (string) Str::uuid(); + $workflow = app(RequisitionWorkflow::class); + $workflow->submit($requester, $purchase, $key); + $purchase->refresh(); + $stepsBefore = $purchase->steps()->get()->toArray(); + $workflow->submit($requester, $purchase, $key); + $purchase->refresh(); + $check($purchase->status === 'pending' && $purchase->steps()->count() > 0 && $purchase->steps()->where('decision', '!=', 'pending')->count() === 0, 'As aprovações têm de permanecer humanas e pendentes.'); + $check($purchase->steps()->get()->toArray() === $stepsBefore && Requisition::query()->count() === $count, 'A repetição não pode duplicar passos ou compras.'); + $check($purchase->items()->get()->toArray() === $itemsBefore && $purchase->total_minor === $next['total_minor_mzn'] && $fingerprint() === $baseline, 'A submissão alterou dados fora do âmbito.'); + $routing = app(ApprovalRouting::class)->current($purchase, $requester); + $check($routing['status'] === 'available', 'A etapa não tem um responsável elegível.'); + $eligible = []; + foreach ($routing['people'] as $person) { + $user = User::query()->findOrFail($person['id']); + auth()->guard('web')->setUser($user); + $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $user->id, 'tenant_id' => $user->tenant_id]); + $check(collect(app(WorkflowTaskInbox::class)->build($user, 'procurement'))->contains('id', 'purchase:'.$purchase->id.':'.$routing['step_id']), 'A etapa não chegou às tarefas do aprovador elegível.'); + $eligible[] = $person; + } + $requester = $actor('requester'); + $check(! collect(app(WorkflowTaskInbox::class)->build($requester, 'procurement'))->contains('id', 'purchase:'.$purchase->id.':draft'), 'Rascunho já submetido continua como preparação pendente.'); + $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', + 'source_business_commands' => false, 'purchase_id' => $purchase->id, 'purchase_number' => $purchase->number, 'new_status_in_copy' => $purchase->status, + 'current_role' => $routing['role'], 'eligible_people' => $eligible, 'all_approval_steps_pending' => true, 'same_command_repeated_without_duplication' => true, + 'items_values_stock_orders_access_preserved' => true, 'sgf_installation_changed' => false]; + $proof['required_command_receipt_migration'] = $migration; + $proof['command_receipt_migration_tested_in_copy'] = $installedInCopy; + } else { + $envelope = fn (array $data, string $action = 'save', ?SupplyRecord $old = null) => ['operation_key' => (string) Str::uuid(), + 'record_key' => $old?->record_key, 'version' => $old?->version ?? 0, 'action' => $action, + 'reason' => 'Simulação descartável da proposta sintética, ainda sem aplicação no piloto.', 'data' => $data]; + $publisher = $actor('catalogue'); + $check((int) DB::table('organization_access')->where('tenant_id', $proposal['tenant_id'])->value('version') === $proposal['expected_access_version'], 'A configuração dos acessos mudou.'); + $stock = StockItem::query()->findOrFail($proposal['source']['stock_item_id']); + $check($stock->only(['code', 'name', 'unit']) === array_intersect_key($proposal['source'], array_flip(['code', 'name', 'unit'])), 'Cadastro diferente da proposta.'); + $warehouse = Warehouse::query()->findOrFail($proposal['source']['warehouse_id']); + $check($warehouse->code === $proposal['source']['warehouse_code'], 'Armazém diferente da proposta.'); + $balance = app(WarehouseAvailability::class)->row($stock, $warehouse, false); + $check($balance['physical_milli'] === $proposal['source']['physical_milli'] && $balance['free_milli'] === $proposal['source']['free_milli'] + && $balance['ledger_version'] === $proposal['source']['ledger_version'], 'Disponibilidade diferente da proposta.'); + $check(! DB::table('requisition_catalogue')->where('tenant_id', $proposal['tenant_id'])->where('stock_item_id', $stock->id)->exists(), 'A publicação inicial mudou.'); + $planning = app(SupplyPlanning::class); + $check($planning->latest('policy')->where('state', 'approved')->where('stock_item_id', $stock->id)->count() === 0, 'Já existe uma política activa.'); + $check($planning->latest('auto_rule')->where('state', 'approved')->where('stock_item_id', $stock->id)->count() === 0, 'Já existe uma regra activa.'); + $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'approval_steps', 'purchase_orders', 'stock_movements']; + $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); + $before = $fingerprint(); + $count = Requisition::query()->count(); + app(RequisitionCatalogue::class)->save($publisher, $stock->id, [...$proposal['catalogue'], 'version' => 0, + 'stock_basis' => app(RequisitionCatalogue::class)->basis($stock), 'reason' => 'Publicação proposta apenas para o ensaio sintético descartável.']); + $prepare = $actor('prepare'); + $policy = $planning->save($prepare, 'policy', $envelope($proposal['policy'])); + $review = $actor('review'); + $tasks = app(WorkflowTaskInbox::class)->build($review, 'procurement'); + $check(collect($tasks)->contains('id', 'supply:'.$policy->id.':review'), 'Política sem encaminhamento independente.'); + $approved = $planning->save($review, 'policy', $envelope([], 'approve', $policy)); + $automatic = app(AutomaticPurchases::class); + $prepare = $actor('prepare'); + $rule = $automatic->save($prepare, $envelope([...$proposal['rule'], 'policy_key' => $approved->record_key])); + $review = $actor('review'); + $check(collect(app(WorkflowTaskInbox::class)->build($review, 'procurement'))->contains('id', 'supply:'.$rule->id.':review'), 'Regra sem encaminhamento independente.'); + $command = $envelope([], 'approve', $rule); + $automatic->save($review, $command); + $automatic->save($review, $command); + $automatic->evaluate($review, $stock->id, $warehouse->id); + $automatic->evaluate($review, $stock->id, $warehouse->id); + $check(Requisition::query()->count() === $count + 1, 'A proposta deve criar exactamente um rascunho.'); + $purchase = Requisition::query()->latest('id')->firstOrFail(); + $check($purchase->status === 'draft' && $purchase->requester_id === $proposal['actors']['requester'] && $purchase->steps()->count() === 0, 'Submissão/aprovações humanas têm de ficar pendentes.'); + $check($purchase->items()->sole()->quantity === $proposal['expected']['quantity'], 'Quantidade gerada não coincide com a proposta.'); + $check($purchase->subtotal_minor === 2000 && $purchase->tax_minor === 320 && $purchase->total_minor === 2320, 'Estimativa de custo diferente da proposta.'); + $check(app(StockWorkflow::class)->balance($stock->id, $warehouse->id)['quantity'] === $proposal['expected']['physical_milli_after'], 'A criação do rascunho não pode alterar stock.'); + $check($fingerprint() === $before, 'A simulação alterou acessos, aprovações, ordens ou stock.'); + $requester = $actor('requester'); + $check(collect(app(WorkflowTaskInbox::class)->build($requester, 'procurement'))->contains('id', 'purchase:'.$purchase->id.':draft'), 'Rascunho sem encaminhamento ao requisitante.'); + $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $proposalFile), 'result' => 'passed', + 'database' => 'disposable restored copy of local synthetic SGC', 'source_business_commands' => false, 'accounts_or_grants_changed' => false, + 'drafts_created_in_copy' => 1, 'quantity' => $purchase->items()->sole()->quantity, 'duplicate_approval_and_two_reevaluations' => 'one draft', + 'estimated_total_minor_mzn' => $purchase->total_minor, 'physical_milli_after' => $proposal['expected']['physical_milli_after'], + 'independent_policy_and_rule_tasks' => true, 'requester_task' => true, 'human_submission_and_approvals_pending' => true, + 'existing_evaluation_context_fallback_reproduced_only_in_copy_runtime' => true, + 'protected_tables_unchanged_in_copy' => $protected, 'sgf_installation_changed' => false, 'visual_review' => false]; + } +} catch (Throwable $error) { + $failure = ['result' => 'failed', 'type' => get_class($error), + 'locations' => array_map(fn ($frame) => ['file' => $frame['file'] ?? null, 'line' => $frame['line'] ?? null], array_slice($error->getTrace(), 0, 9))]; + if ($error instanceof ValidationException) { + $failure['validation'] = $error->errors(); + } +} finally { + DB::purge('pilot_clone'); + if ($created) { + $admin->statement('DROP DATABASE "'.$clone.'" WITH (FORCE)'); + } + if (is_file($dump) && dirname(realpath($dump)) === realpath($privateRoot)) { + unlink($dump); + } +} +if ($failure !== null) { + fwrite(STDERR, json_encode($failure, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL); + exit(1); +} +if ($proof !== null) { + $proof['database_and_private_copy_removed'] = true; + $proofName = in_array('--evaluations', $argv, true) ? 'pilot-evaluation-dry-run' : (in_array('--market', $argv, true) ? 'pilot-market-dry-run' : (in_array('--approval', $argv, true) ? 'pilot-approval-dry-run' : (in_array('--submission', $argv, true) ? 'pilot-submission-dry-run' : 'pilot-replenishment-dry-run'))); + file_put_contents($root.'/docs/proofs/'.$proofName.'.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL); + echo 'Proposta ensaiada na cópia descartável; piloto sem comandos de negócio.'.PHP_EOL; +} diff --git a/scripts/pilot-submission-activation-http.mjs b/scripts/pilot-submission-activation-http.mjs new file mode 100644 index 0000000..3badf91 --- /dev/null +++ b/scripts/pilot-submission-activation-http.mjs @@ -0,0 +1,25 @@ +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 records=[]; + for(const role of ['requester','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'); assert.equal(tasks.status,200); + const rows=tasks.page.props.tasks.data.filter(row=>row.id.startsWith('purchase:2:')); + if(role==='requester') assert.equal(rows.length,0); + else {assert.equal(rows.length,1);assert.equal(rows[0].task,'approve');} + const detail=await viewer.request('/requisicoes/2');assert.equal(detail.status,200); + const r=detail.page.props.requisition;assert.equal(r.number,'REQ-2026-000002');assert.equal(r.status,'pending');assert.equal(r.total_minor,2320); + assert.equal(r.items[0].quantity,'2.000');assert.ok(r.steps.length>0);assert.ok(r.steps.every(step=>step.decision==='pending')); + assert.equal(detail.page.props.canEdit,false);assert.equal(detail.page.props.canDecide,role==='department_head'); + assert.deepEqual(detail.page.props.approvalRouting.people.map(person=>person.id),[10]); + records.push({role,status:r.status,task_count:rows.length,can_decide:detail.page.props.canDecide}); + } + await fs.writeFile('docs/proofs/pilot-submission-activation-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + quantity:'2.000',total_minor_mzn:2320,all_approval_steps_pending:true,business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false},null,2)); + console.log('Submissao e encaminhamento confirmados, sem aprovar a compra.'); +` + '})()')(fs, assert); diff --git a/scripts/policy-source-http.mjs b/scripts/policy-source-http.mjs new file mode 100644 index 0000000..0bdd470 --- /dev/null +++ b/scripts/policy-source-http.mjs @@ -0,0 +1,24 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const result=await viewer.request('/compras/fornecimento?view=suggestions&policy_search=ART&policy_form_key=suggestion:new'); + assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true);assert.equal(result.page.props.canPrepare,false); + assert.deepEqual(result.page.props.policies,[]);assert.equal(result.page.props.policyFormKey,'suggestion:new'); + assert.equal((await viewer.request('/compras/fornecimento?view=suggestions&policy_search='+ 'A'.repeat(101))).status,422); + } + records.push({role,status:result.status}); + } + await fs.writeFile('docs/proofs/policy-source-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Existing pilot access and lookup validation; positive source choices and suspensions tested in disposable databases.'},null,2)); + console.log('Pesquisa de políticas conferida sem criar sugestões, compras ou competências no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/receipt-block-installation-http.mjs b/scripts/receipt-block-installation-http.mjs new file mode 100644 index 0000000..c1cad81 --- /dev/null +++ b/scripts/receipt-block-installation-http.mjs @@ -0,0 +1,23 @@ +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 records=[]; + for(const role of ['admin','warehouse','procurement']) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const result=await viewer.request('/recepcoes'); assert.equal(result.status,200); + const rows=result.page.props.receipts.data; + assert.ok(Array.isArray(rows)); + for(const row of rows.slice(0,2)) { + const detail=await viewer.request('/recepcoes/'+row.id); assert.equal(detail.status,200); + assert.equal(detail.page.props.documentsReady,true); + assert.ok(Array.isArray(detail.page.props.documents)); + assert.ok(detail.page.props.documents.every(d=>d.path===undefined)); + } + records.push({role,status:result.status,detailsChecked:Math.min(rows.length,2)}); + } + await fs.writeFile('docs/proofs/receipt-block-installation-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records,business_commands:false,documents_uploaded:false,visual_review:false},null,2)); + console.log('Recepções consultadas no piloto; sem comandos de negócio.'); +`+'})()')(fs,assert); diff --git a/scripts/replenishment-health-http.mjs b/scripts/replenishment-health-http.mjs new file mode 100644 index 0000000..d543696 --- /dev/null +++ b/scripts/replenishment-health-http.mjs @@ -0,0 +1,24 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403]]) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const result=await viewer.request('/stock/piloto');assert.equal(result.status,expected); + if(expected===200) { + const runtime=result.page.props.report.runtime; + assert.equal(typeof runtime.schedulerRecent,'boolean'); + assert.equal(runtime.schedulerBound,undefined); + assert.equal(runtime.recoveryReady,true); + assert.ok(Number(runtime.work.total)>=0); + } + records.push({role,status:result.status}); + } + await fs.writeFile('docs/proofs/replenishment-health-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + pilot_health_command_executed:false,business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing tenant-scoped diagnostic UI and rights preserved; CLI health executed only in disposable databases.'},null,2)); + console.log('Diagnóstico do piloto conferido em leitura, sem executar reposições.'); +`+'})()')(fs,assert); diff --git a/scripts/requisition-audit-http.mjs b/scripts/requisition-audit-http.mjs new file mode 100644 index 0000000..f8f1560 --- /dev/null +++ b/scripts/requisition-audit-http.mjs @@ -0,0 +1,25 @@ +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 records=[]; + for(const role of ['admin','procurement','finance','warehouse']) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const detail=await viewer.request('/requisicoes/2'); + const result=await viewer.request('/requisicoes/2/historico.csv','GET',undefined,false); + assert.equal(result.status,detail.status); + if(role==='admin') assert.equal(result.status,200); + if(result.status===200) { + assert.ok(result.text.includes('Metadados do histórico da requisição')); + assert.ok(result.text.includes('Modo de execução')); + assert.ok(!result.text.includes('account_identifier')); + } + records.push({role,detail:detail.status,export:result.status}); + } + await fs.writeFile('docs/proofs/requisition-audit-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Read-only metadata export of existing synthetic requisition 2, compared with native detail visibility.'},null,2)); + console.log('Metadados do histórico conferidos no piloto sem decisões ou alterações de dados.'); +`+'})()')(fs,assert); diff --git a/scripts/reservation-source-http.mjs b/scripts/reservation-source-http.mjs new file mode 100644 index 0000000..eef367b --- /dev/null +++ b/scripts/reservation-source-http.mjs @@ -0,0 +1,23 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const result=await viewer.request('/compras/fornecimento?view=reservations');assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true);assert.equal(result.page.props.canPrepare,false);assert.deepEqual(result.page.props.operations,[]); + assert.equal((await viewer.request('/compras/fornecimento?view=reservations&issue_search=TEST')).status,422); + assert.equal((await viewer.request('/compras/fornecimento?view=reservations&issue_record_id=999999&issue_search=TEST')).status,422); + } + records.push({role,status:result.status}); + } + await fs.writeFile('docs/proofs/reservation-source-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Existing pilot access and source binding validation; positive searches and associations exercised in disposable databases.'},null,2)); + console.log('Pesquisa de saídas conferida sem conceder competências ou criar movimentos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/suggestion-task-integration-http.mjs b/scripts/suggestion-task-integration-http.mjs new file mode 100644 index 0000000..cbeed11 --- /dev/null +++ b/scripts/suggestion-task-integration-http.mjs @@ -0,0 +1,25 @@ +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,expected] of [['admin',200],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const response=await viewer.request('/compras/fornecimento?view=suggestions');assert.equal(response.status,expected); + if(expected===200) { + assert.equal(response.page.component,'supply/suggestions');assert.equal(response.page.encryptHistory,true); + assert.equal(response.page.props.records.length,0);assert.equal(response.page.props.canPrepare,false); + assert.equal(response.page.props.canReview,false);assert.equal(response.page.props.canConvert,false); + } + const tasks=await viewer.request('/requisicoes/tarefas?module=supply');assert.equal(tasks.status,200); + const pending=tasks.page.props.tasks.data.filter(row=>row.task.startsWith('suggestion_'));assert.equal(pending.length,0); + routes.push({role,suggestions_status:response.status,suggestion_tasks:pending.length}); + } + await fs.writeFile('docs/proofs/suggestion-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:'Live synthetic scope has no manual suggestions or specific execution grants; review, conversion and closure tested in disposable databases.'},null,2)); + console.log('Sugestoes e tarefas conferidas, preservando competencias especificas.'); +` + '})()')(fs, assert); diff --git a/scripts/supplier-improvement-task-integration-http.mjs b/scripts/supplier-improvement-task-integration-http.mjs new file mode 100644 index 0000000..b0d7224 --- /dev/null +++ b/scripts/supplier-improvement-task-integration-http.mjs @@ -0,0 +1,20 @@ +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 records=[]; + 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 page=await viewer.request('/fornecedores/avaliacoes');assert.equal(page.status,200); + assert.equal(page.page.encryptHistory,true); + const tasks=await viewer.request('/requisicoes/tarefas?module=purchases');assert.equal(tasks.status,200); + records.push({role,evaluation_page:page.status,quality_evaluations:page.page.props.records.total, + improvement_tasks:tasks.page.props.tasks.data.filter(row=>row.task.startsWith('supplier_improvement_')).length}); + } + await fs.writeFile('docs/proofs/supplier-improvement-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,created_quality_evaluations_or_measures:false,visual_review:false},null,2)); + console.log('Consulta de qualidade e tarefas conferidas, sem criar avaliações ou medidas no piloto.'); +` + '})()')(fs, assert); diff --git a/scripts/supplier-quality-task-integration-http.mjs b/scripts/supplier-quality-task-integration-http.mjs new file mode 100644 index 0000000..0666323 --- /dev/null +++ b/scripts/supplier-quality-task-integration-http.mjs @@ -0,0 +1,35 @@ +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 records=[]; + 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 result=await viewer.request('/requisicoes/tarefas?module=purchases&search=AVA-');assert.equal(result.status,200); + assert.equal(result.page.encryptHistory,true); + const index=await viewer.request('/fornecedores/avaliacoes');assert.equal(index.status,200);assert.equal(index.page.props.canCreate,true); + const tasks=result.page.props.tasks; + const checked=[]; + for(const row of tasks.data) { + assert.equal(row.module,'purchases');assert.equal('payload' in row,false);assert.equal('decision' in row,false); + const detail=await viewer.request(row.href.split('#')[0]);assert.equal(detail.status,200); + assert.equal(detail.page.encryptHistory,true); + if(row.task.startsWith('supplier_evaluation_')) assert.equal(detail.page.props[row.task.endsWith('_review')?'canReview':'canEdit'],true); + if(row.task.startsWith('supplier_response_')) { + const id=Number(row.id.split(':')[1]); + const response=detail.page.props.responses.records.find(r=>r.id===id);assert.ok(response); + assert.equal(response[row.task.endsWith('_review')?'canReview':'canEdit'],true); + } + checked.push({id:row.id,task:row.task,detail:200}); + } + const quotes=await viewer.request('/requisicoes/2/cotacoes');assert.equal(quotes.status,200);assert.equal(quotes.page.props.ready,true); + assert.equal(quotes.page.props.quotations.reduce((n,r)=>n+r.evaluations.length,0),6); + records.push({role,total_quality_tasks:tasks.total,checked,proposal_evaluations:6}); + } + await fs.writeFile('docs/proofs/supplier-quality-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,supplier_selection_or_order:false,visual_review:false},null,2)); + console.log('Tarefas de qualidade, páginas autorizadas e seis avaliações anteriores conferidas sem decisões novas.'); +` + '})()')(fs, assert); diff --git a/scripts/supplier-task-integration-http.mjs b/scripts/supplier-task-integration-http.mjs new file mode 100644 index 0000000..cafce91 --- /dev/null +++ b/scripts/supplier-task-integration-http.mjs @@ -0,0 +1,29 @@ +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 records=[]; + for(const role of ['procurement','finance','requester']) { + 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 pending=tasks.page.props.tasks.data.filter(row=>row.task==='supplier_review');assert.equal(pending.length,0); + for(const id of [2,3]) { + const response=await viewer.request('/fornecedores/'+id); + assert.equal(response.status,role==='requester'?403:200); + if(role!=='requester') { + assert.equal(response.page.encryptHistory,true); + assert.equal(response.page.props.supplier.status,'active'); + assert.equal(response.page.props.canReview,role==='finance'); + assert.equal(response.page.props.canEdit,role==='procurement'); + } + } + records.push({role,pending_supplier_tasks:pending.length,supplier_detail_status:role==='requester'?403:200}); + } + await fs.writeFile('docs/proofs/supplier-task-integration-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Current supplier records are active; pending review, stale forms and revoked authority tested in disposable databases.'},null,2)); + console.log('Cadastros, tarefas e competencias de preparacao e conferencia confirmados.'); +` + '})()')(fs, assert); diff --git a/scripts/supply-line-source-http.mjs b/scripts/supply-line-source-http.mjs new file mode 100644 index 0000000..3806d37 --- /dev/null +++ b/scripts/supply-line-source-http.mjs @@ -0,0 +1,25 @@ +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 records=[]; + for(const [role,expected] of [['admin',200],['warehouse',403],['procurement',403],['finance',403]]) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const view of ['reservations','deliveries']) { + const result=await viewer.request('/compras/fornecimento?view='+view+'&line_search=TEST&line_form_key=prepare:new');assert.equal(result.status,expected); + if(expected===200) { + assert.equal(result.page.encryptHistory,true);assert.equal(result.page.props.canPrepare,false); + assert.deepEqual(result.page.props.orderLines,[]);assert.deepEqual(result.page.props.requestLines,[]); + assert.equal((await viewer.request('/compras/fornecimento?view='+view+'&line_search='+ 'A'.repeat(101))).status,422); + } + records.push({role,view,status:result.status}); + } + } + await fs.writeFile('docs/proofs/supply-line-source-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Existing pilot access; positive canonical line searches and planning commands tested in disposable databases.'},null,2)); + console.log('Pesquisa de documentos de fornecimento conferida sem novas decisões ou acessos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/supply-pagination-http.mjs b/scripts/supply-pagination-http.mjs new file mode 100644 index 0000000..44dbdae --- /dev/null +++ b/scripts/supply-pagination-http.mjs @@ -0,0 +1,37 @@ +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 records=[]; + for(const role of ['admin','procurement','finance']) { + const viewer=client();await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const pages=[]; + for(const view of ['agreements','calendar','history','suggestions','imports']) { + const result=await viewer.request('/compras/fornecimento?view='+view); + assert.ok([200,403].includes(result.status)); + if(role==='admin'||['agreements','calendar'].includes(view)) assert.equal(result.status,200); + if(result.status===200) { + assert.equal(result.page.encryptHistory,true);assert.ok(result.page.props.recordPagination); + assert.ok(result.page.props.records.length<=25); + if(role==='admin') { + assert.equal(result.page.props.canPrepare,false); + assert.equal((await viewer.request('/compras/fornecimento?view='+view+'&records_page=0')).status,422); + } + } + if(view==='imports') { + const search=await viewer.request('/compras/fornecimento?view=imports&order_search=TEST'); + assert.equal(search.status,result.status); + if(search.status===200) { assert.equal(search.page.props.canPrepare,false); assert.deepEqual(search.page.props.orders,[]); } + } + pages.push({view,status:result.status,total:result.page?.props?.recordPagination?.total??null}); + } + records.push({role,pages}); + } + await fs.writeFile('docs/proofs/supply-pagination-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false, + coverage:'Current pilot pagination and existing role access; old records and selected sources exercised in disposable databases.'},null,2)); + console.log('Consultas de fornecimento paginadas conferidas sem novas decisões ou acessos no piloto.'); +`+'})()')(fs,assert); diff --git a/scripts/supply-task-integration-http.mjs b/scripts/supply-task-integration-http.mjs new file mode 100644 index 0000000..5e390dc --- /dev/null +++ b/scripts/supply-task-integration-http.mjs @@ -0,0 +1,38 @@ +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, prepare, review] of [['procurement', true, false], ['finance', false, true]]) { + 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=supply'); + assert.equal(tasks.status, 200); + for (const task of tasks.page.props.tasks.data) { + const detail = await viewer.request(task.href); + assert.equal(detail.status, 200); + const rows = detail.page.props.records ?? detail.page.props.rules?.data ?? []; + assert.equal(rows.length, 1); + assert.equal(rows[0].canApprove, true); + } + const components = []; + for (const path of ['/compras/fornecimento?view=agreements', '/compras/fornecimento?view=calendar', '/stock/reposicao']) { + const response = await viewer.request(path); + assert.equal(response.status, 200); + assert.equal(response.page.encryptHistory, true); + assert.equal(response.page.props.canPrepare, prepare); + assert.equal(response.page.props.canReview, review); + components.push({ path, component: response.page.component, result: 'passed' }); + } + routes.push({ role, supply_tasks_shown: tasks.page.props.tasks.data.length, task_links_followed: tasks.page.props.tasks.data.length, components }); + } + await fs.writeFile('docs/proofs/supply-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('Fornecimentos, reposicao e tarefas conferidos nos perfis sinteticos existentes.'); +` + '})()')(fs, assert); diff --git a/scripts/support/pilot-market-scenario.php b/scripts/support/pilot-market-scenario.php new file mode 100644 index 0000000..14d0a71 --- /dev/null +++ b/scripts/support/pilot-market-scenario.php @@ -0,0 +1,60 @@ +id === $next['preparer_id'], 'Preparador diferente da proposta.'); + $purchase = Requisition::query()->findOrFail($next['purchase_id']); + $check($purchase->status === 'approved' && $purchase->number === $next['purchase_number'] && $purchase->items()->sole()->quantity === '2.000' && $purchase->total_minor === 2320, 'Compra diferente da proposta.'); + $check(Quotation::query()->where('requisition_id', $purchase->id)->count() === 0, 'Já existem propostas: não duplicar.'); + $existing = Supplier::query()->findOrFail($next['existing_eligible_supplier_id']); + $check($existing->eligible(), 'O fornecedor existente deixou de estar elegível.'); + $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'purchase_orders', 'stock_movements', 'requisitions', 'requisition_items', 'budget_entries', 'purchase_awards']; + $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); + $baseline = $fingerprint(); + $suppliersBefore = DB::table('suppliers')->orderBy('id')->get()->toArray(); + $supplierIds = ['existing' => $existing->id]; + $workflow = app(RequisitionWorkflow::class); + foreach ($next['new_synthetic_suppliers'] as $index => $values) { + $preparer = $actor('prepare'); + $form = SaveSupplierRequest::create($proposal['environment'].'/fornecedores', 'POST', [...$next['synthetic_supplier_fields'], ...$values]); + $form->setContainer($app)->setRedirector($app['redirect']); + $form->setUserResolver(fn () => $preparer); + $form->validateResolved(); + app(SupplierController::class)->store($form, $workflow); + $supplier = Supplier::query()->where('nuit', $values['nuit'])->sole(); + $reviewer = $actor('review'); + $check($reviewer->id === $next['supplier_reviewer_id'] && $supplier->created_by !== $reviewer->id, 'Conferência sem independência.'); + $review = Request::create($proposal['environment'].'/fornecedores/'.$supplier->id.'/validar', 'POST', ['version' => $supplier->version, 'status' => 'active', + 'comment' => 'Activação exclusivamente sintética para o piloto local; não representa validação comercial real.']); + $review->setUserResolver(fn () => $reviewer); + app(SupplierController::class)->review($review, $supplier, $workflow); + $supplierIds[$index === 0 ? 'alternative_a' : 'alternative_b'] = $supplier->id; + } + $preparer = $actor('prepare'); + foreach ($next['quotations'] as $quote) { + $input = Request::create($proposal['environment'].'/requisicoes/'.$purchase->id.'/cotacoes', 'POST', ['supplier_id' => $supplierIds[$quote['supplier']], + 'reference' => $quote['reference'], 'delivery_days' => $quote['delivery_days'], 'valid_until' => $next['quotation_valid_until'], 'payment_terms' => $next['payment_terms'], + 'no_conflict' => true, 'items' => [['requisition_item_id' => $purchase->items()->sole()->id, 'unit_price' => $quote['unit_price']]]]); + $input->setUserResolver(fn () => $preparer); + app(QuotationController::class)->store($input, $purchase, $workflow); + $saved = Quotation::query()->where('requisition_id', $purchase->id)->where('reference', $quote['reference'])->sole(); + $check($saved->total_minor === $quote['expected_total_minor'], 'Totais diferentes da proposta sintética.'); + } + $check($fingerprint() === $baseline && Quotation::query()->where('requisition_id', $purchase->id)->count() === 3, 'A consulta alterou dados fora do âmbito.'); + $check(DB::table('suppliers')->whereNotIn('id', [$supplierIds['alternative_a'], $supplierIds['alternative_b']])->orderBy('id')->get()->toArray() == $suppliersBefore, 'Cadastro anterior alterado.'); + $check(Quotation::query()->where('requisition_id', $purchase->id)->withCount('evaluations')->get()->sum('evaluations_count') === 0, 'Não avaliar automaticamente.'); + + return ['supplier_ids' => $supplierIds, 'quotation_ids' => Quotation::query()->where('requisition_id', $purchase->id)->orderBy('id')->pluck('id')->all(), 'protected_tables' => $protected]; +} diff --git a/scripts/task-deadlines-http.mjs b/scripts/task-deadlines-http.mjs new file mode 100644 index 0000000..7355694 --- /dev/null +++ b/scripts/task-deadlines-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 records=[]; + for(const role of ['admin','warehouse','procurement','finance']) { + const viewer=client(); await viewer.enter(role); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + for(const due of ['all','overdue','today','upcoming','undated']) { + const result=await viewer.request('/requisicoes/tarefas?due='+due); + assert.equal(result.status,200); + const props=result.page.props; + assert.equal(result.page.encryptHistory,true); + assert.equal(props.filters.due,due); + assert.match(props.asOf,/^\\d{4}-\\d{2}-\\d{2}$/); + if(due!=='all') { + assert.equal(props.tasks.total,props.deadlineCounts[due]); + assert.ok(props.tasks.data.every(task=>task.due_status===due)); + } else { + assert.equal(props.tasks.total,Object.values(props.deadlineCounts).reduce((sum,value)=>sum+value,0)); + } + records.push({role,due,status:result.status,total:props.tasks.total}); + } + assert.equal((await viewer.request('/requisicoes/tarefas?due=invalid')).status,422); + } + await fs.writeFile('docs/proofs/task-deadlines-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed',records, + business_commands:false,accounts_or_grants_changed:false,sgf_changed:false,visual_review:false, + coverage:'Existing synthetic profiles and actual task projections; populated date boundary and pagination scenarios tested in disposable databases.'},null,2)); + console.log('Prazos da caixa de tarefas conferidos em leitura no piloto.'); +` + '})()')(fs, assert); diff --git a/scripts/warehouse-task-pilot-status-http.mjs b/scripts/warehouse-task-pilot-status-http.mjs new file mode 100644 index 0000000..81798ef --- /dev/null +++ b/scripts/warehouse-task-pilot-status-http.mjs @@ -0,0 +1,20 @@ +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('finance'); + assert.equal((await viewer.request('/contexto','POST',{area:'procurement',option:'procurement.internal'})).status,302); + const quotes=await viewer.request('/requisicoes/2/cotacoes');assert.equal(quotes.status,200); + assert.equal(quotes.page.props.quotations.length,3);assert.equal(quotes.page.props.ready,false); + assert.ok(quotes.page.props.quotations.every(row=>row.evaluations.length===0)); + const request=await viewer.request('/requisicoes/2');assert.equal(request.status,200); + assert.equal(request.page.props.requisition.status,'approved');assert.equal(request.page.props.requisition.total_minor,2320); + const availability=await viewer.request('/stock/disponibilidade?warehouse_id=1');assert.equal(availability.status,200); + assert.equal(availability.page.props.rows.data.find(row=>row.id===1).physical_milli,1000); + await fs.writeFile('docs/proofs/warehouse-task-pilot-status-http.json',JSON.stringify({checked_at:new Date().toISOString(),environment:base,result:'passed', + purchase_id:2,purchase_status:'approved',total_minor_mzn:2320,quotations:3,evaluations:0,comparison_ready:false,physical_milli:1000, + business_commands:false,accounts_or_grants_changed:false,sgf_installation_changed:false,visual_review:false},null,2)); + console.log('Estado do piloto preservado: tres propostas sem avaliacoes e saldo de uma caixa.'); +` + '})()')(fs, assert); diff --git a/scripts/work-context-pg-check.php b/scripts/work-context-pg-check.php index 89ed2b1..863f08e 100644 --- a/scripts/work-context-pg-check.php +++ b/scripts/work-context-pg-check.php @@ -31,6 +31,39 @@ $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); +$supplyTasks = in_array('--supply-tasks', $argv ?? [], true); +$historyTasks = in_array('--history-tasks', $argv ?? [], true); +$suggestionTasks = in_array('--suggestion-tasks', $argv ?? [], true); +$alternativeTasks = in_array('--alternative-tasks', $argv ?? [], true); +$supplierTasks = in_array('--supplier-tasks', $argv ?? [], true); +$dispositionTasks = in_array('--disposition-tasks', $argv ?? [], true); +$inventoryTasks = in_array('--inventory-tasks', $argv ?? [], true); +$lotTasks = in_array('--lot-tasks', $argv ?? [], true); +$improvementTasks = in_array('--improvement-tasks', $argv ?? [], true); +$qualityTasks = in_array('--quality-tasks', $argv ?? [], true); +$coordinationTasks = in_array('--coordination-tasks', $argv ?? [], true); +$lotQualityTasks = in_array('--lot-quality-tasks', $argv ?? [], true); +$lotPagination = in_array('--lot-pagination', $argv ?? [], true); +$supplyPagination = in_array('--supply-pagination', $argv ?? [], true); +$correctionPagination = in_array('--correction-pagination', $argv ?? [], true); +$reservationSources = in_array('--reservation-sources', $argv ?? [], true); +$historySources = in_array('--history-sources', $argv ?? [], true); +$policySources = in_array('--policy-sources', $argv ?? [], true); +$correctionSources = in_array('--correction-sources', $argv ?? [], true); +$supplyLineSources = in_array('--supply-line-sources', $argv ?? [], true); +$coordinationFilters = in_array('--coordination-filters', $argv ?? [], true); +$coordinationExport = in_array('--coordination-export', $argv ?? [], true); +$taskDeadlines = in_array('--task-deadlines', $argv ?? [], true); +$noticeConsultation = in_array('--notice-consultation', $argv ?? [], true); +$agreementSources = in_array('--agreement-sources', $argv ?? [], true); +$mfaFoundation = in_array('--mfa-foundation', $argv ?? [], true); +$mfaManagement = in_array('--mfa-management', $argv ?? [], true); +$requisitionAudit = in_array('--requisition-audit', $argv ?? [], true); +$auditPagination = in_array('--audit-pagination', $argv ?? [], true); +$neededAttention = in_array('--needed-attention', $argv ?? [], true); +$approvedAttention = in_array('--approved-attention', $argv ?? [], true); +$replenishmentHealth = in_array('--replenishment-health', $argv ?? [], true); +$receiptBlock = in_array('--receipt-block', $argv ?? [], true); $inbox = in_array('--inbox', $argv ?? [], true) || $transport; $warehouse = in_array('--warehouse', $argv ?? [], true) || $pilot || $workdesk || $readiness; try { @@ -77,7 +110,135 @@ try { 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); + if ($supplyTasks) { + $tests = ['tests/Feature/SupplyTaskIntegrationTest.php', 'tests/Feature/AutomaticPurchasesTest.php']; + } + if ($historyTasks) { + $tests = ['tests/Feature/SupplyHistoryTest.php', 'tests/Feature/WarehousePilotReadinessTest.php']; + } + if ($suggestionTasks) { + $tests = ['tests/Feature/SupplySuggestionsTest.php']; + } + if ($alternativeTasks) { + $tests = ['tests/Feature/SupplyCoordinationTest.php']; + } + if ($supplierTasks) { + $tests = ['tests/Feature/SupplierMaintenanceTest.php']; + } + if ($dispositionTasks) { + $tests = ['tests/Feature/InventoryDispositionsTest.php']; + } + if ($inventoryTasks) { + $tests = ['tests/Feature/InventoryDispositionsTest.php', 'tests/Feature/InventoryReversalsTest.php']; + } + if ($lotTasks) { + $tests = ['tests/Feature/InventoryLotsTest.php']; + } + if ($improvementTasks) { + $tests = ['tests/Feature/SupplierImprovementTest.php']; + } + if ($qualityTasks) { + $tests = ['tests/Feature/SupplierEvaluationTest.php', 'tests/Feature/SupplierResponseTest.php']; + } + if ($coordinationTasks) { + $tests = ['tests/Feature/SupplyCoordinationTest.php', 'tests/Feature/InventoryReversalsTest.php']; + } + if ($lotQualityTasks) { + $tests = ['tests/Feature/InventoryLotsTest.php']; + } + if ($lotPagination) { + $tests = ['tests/Feature/InventoryLotsTest.php']; + } + if ($supplyPagination) { + $tests = ['tests/Feature/SupplyPaginationTest.php', 'tests/Feature/SupplyHistoryTest.php']; + } + if ($correctionPagination) { + $tests = ['tests/Feature/InventoryCorrectionPaginationTest.php']; + } + if ($reservationSources) { + $tests = ['tests/Feature/SupplyCoordinationTest.php', 'tests/Feature/InventoryReversalsTest.php']; + } + if ($historySources) { + $tests = ['tests/Feature/SupplyHistoryTest.php']; + } + if ($policySources) { + $tests = ['tests/Feature/SupplySuggestionsTest.php']; + } + if ($correctionSources) { + $tests = ['tests/Feature/InventoryCorrectionPaginationTest.php']; + } + if ($supplyLineSources) { + $tests = ['tests/Feature/SupplyCoordinationTest.php']; + } + if ($coordinationFilters || $coordinationExport) { + $tests = ['tests/Feature/SupplyCoordinationTest.php']; + } + if ($taskDeadlines) { + $tests = ['tests/Feature/WorkflowTaskInboxTest.php', 'tests/Feature/SupplyCoordinationTest.php']; + } + if ($noticeConsultation) { + $tests = ['tests/Feature/InternalRequisitionCandidateTest.php']; + } + if ($agreementSources) { + $tests = ['tests/Feature/SupplyPaginationTest.php']; + } + if ($mfaFoundation) { + $tests = ['tests/Feature/Auth/TwoFactorFoundationTest.php']; + } + if ($mfaManagement) { + $tests = ['tests/Feature/Settings/TwoFactorManagementTest.php']; + } + if ($requisitionAudit || $auditPagination) { + $tests = ['tests/Feature/InternalRequisitionCandidateTest.php']; + } + if ($neededAttention || $approvedAttention) { + $tests = ['tests/Feature/ProcurementReportsTest.php']; + } + if ($replenishmentHealth) { + $tests = ['tests/Feature/ReplenishmentHealthTest.php']; + } + if ($receiptBlock) { + $tests = ['tests/Feature/ReceiptDocumentBlockTest.php']; + } + $filter = match (true) { + $receiptBlock => ['--filter=receipt document block'], + $replenishmentHealth => ['--filter=replenishment health'], + $approvedAttention => ['--filter=approved attention reports'], + $neededAttention => ['--filter=needed attention report'], + $auditPagination => ['--filter=requisition audit export'], + $requisitionAudit => ['--filter=requisition audit export'], + $mfaManagement => ['--filter=mfa management'], + $mfaFoundation => ['--filter=two factor foundation'], + $agreementSources => ['--filter=agreement source search'], + $noticeConsultation => ['--filter=notice consultation'], + $taskDeadlines => ['--filter=task deadline'], + $coordinationExport => ['--filter=coordination export'], + $coordinationFilters => ['--filter=coordination record filters'], + $supplyLineSources => ['--filter=supply line search'], + $correctionSources => ['--filter=older selected|correction source search'], + $policySources => ['--filter=policy source search'], + $historySources => ['--filter=history source search'], + $reservationSources => ['--filter=reservation source search|coordination task returns'], + $supplyPagination => ['--filter=supply pagination'], + $lotPagination => ['--filter=lot pagination'], + $lotQualityTasks => ['--filter=lot quality task'], + $coordinationTasks => ['--filter=coordination task'], + $qualityTasks => ['--filter=quality task|response task'], + $improvementTasks => ['--filter=improvement task'], + $lotTasks => ['--filter=lot setup task'], + $inventoryTasks => ['--filter=disposition task|reversal task'], + $dispositionTasks => ['--filter=disposition task'], + $supplierTasks => ['--filter=supplier task'], + $alternativeTasks => ['--filter=alternative task'], + $suggestionTasks => ['--filter=suggestion task'], + $historyTasks => ['--filter=history task|command readiness'], + $supplyTasks => ['--filter=supply task'], + $formalTasks => ['--filter=formal task'], + $quotationTasks => ['--filter=quotation tasks'], + $awardTasks => ['--filter=task inbox'], + default => [], + }; + $process = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', ...$tests, ...$filter], dirname(__DIR__), $environment, timeout: $financeIntegration || $awardTasks || $quotationTasks || $organizationPreview || $pilotAccessProposal || $formalTasks || $supplyTasks || $historyTasks || $suggestionTasks || $alternativeTasks || $supplierTasks || $dispositionTasks || $inventoryTasks || $lotTasks || $improvementTasks || $qualityTasks || $coordinationTasks || $lotQualityTasks || $lotPagination || $supplyPagination || $correctionPagination || $reservationSources || $historySources || $policySources || $correctionSources || $supplyLineSources || $noticeConsultation ? 300 : 120); $process->run(); echo $process->getOutput(); if (! $process->isSuccessful()) { @@ -206,6 +367,39 @@ try { DB::purge('warehouse_proof_read'); } $proofName = match (true) { + $receiptBlock => 'receipt-block-pg', + $replenishmentHealth => 'replenishment-health-pg', + $approvedAttention => 'approved-attention-pg', + $neededAttention => 'needed-attention-pg', + $auditPagination => 'audit-pagination-pg', + $requisitionAudit => 'requisition-audit-pg', + $mfaManagement => 'mfa-management-pg', + $mfaFoundation => 'mfa-foundation-pg', + $agreementSources => 'agreement-source-pg', + $noticeConsultation => 'notice-consultation-pg', + $taskDeadlines => 'task-deadlines-pg', + $coordinationExport => 'coordination-export-pg', + $coordinationFilters => 'coordination-filters-pg', + $supplyLineSources => 'supply-line-source-pg', + $correctionSources => 'correction-source-pg', + $policySources => 'policy-source-pg', + $historySources => 'history-source-pg', + $reservationSources => 'reservation-source-pg', + $correctionPagination => 'inventory-pagination-pg', + $supplyPagination => 'supply-pagination-pg', + $lotPagination => 'lot-pagination-pg', + $lotQualityTasks => 'lot-quality-task-integration-pg', + $coordinationTasks => 'coordination-task-integration-pg', + $qualityTasks => 'supplier-quality-task-integration-pg', + $improvementTasks => 'supplier-improvement-task-integration-pg', + $lotTasks => 'lot-task-integration-pg', + $inventoryTasks => 'inventory-task-integration-pg', + $dispositionTasks => 'disposition-task-integration-pg', + $supplierTasks => 'supplier-task-integration-pg', + $alternativeTasks => 'alternative-task-integration-pg', + $suggestionTasks => 'suggestion-task-integration-pg', + $historyTasks => 'history-task-integration-pg', + $supplyTasks => 'supply-task-integration-pg', $formalTasks => 'formal-task-integration-pg', $pilotAccessProposal => 'pilot-access-proposal-pg', $organizationPreview => 'organization-activation-preview-pg', diff --git a/storage/framework/replenishment-status.json b/storage/framework/replenishment-status.json index 7b6ec6a..946eb24 100644 --- a/storage/framework/replenishment-status.json +++ b/storage/framework/replenishment-status.json @@ -1,6 +1,6 @@ { - "started_at": "2026-11-02T18:26:48+00:00", - "finished_at": "2026-11-02T18:26:50+00:00", + "started_at": "2026-11-02T19:42:15+00:00", + "finished_at": "2026-11-02T19:42:19+00:00", "pid": 24528, "scope": "SGC evaluation 8005", "result": "passed", diff --git a/tests/Feature/Auth/TwoFactorFoundationTest.php b/tests/Feature/Auth/TwoFactorFoundationTest.php new file mode 100644 index 0000000..db0e063 --- /dev/null +++ b/tests/Feature/Auth/TwoFactorFoundationTest.php @@ -0,0 +1,79 @@ +toBeFalse(); + $user = User::factory()->create(); + app(EnableTwoFactorAuthentication::class)($user); + expect($user->toArray())->not->toHaveKeys(['two_factor_secret', 'two_factor_recovery_codes']); + expect($user->two_factor_secret)->not->toBe(decrypt($user->two_factor_secret)); + $this->get('/two-factor-challenge')->assertNotFound(); +}); + +test('two factor foundation challenges a confirmed account and accepts a recovery code only once', function () { + foundationMfaRoutes(); + $user = User::factory()->create(); + app(EnableTwoFactorAuthentication::class)($user); + $user->forceFill(['two_factor_confirmed_at' => now()])->save(); + $code = $user->recoveryCodes()[0]; + $this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge'); + $this->assertGuest(); + $this->get('/two-factor-challenge')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private') + ->assertInertia(fn (Assert $p) => $p->component('auth/two-factor-challenge')->missing('secret')->missing('recoveryCodes')); + $this->post('/two-factor-challenge', ['recovery_code' => $code])->assertRedirect(); + $this->assertAuthenticatedAs($user); + expect($user->fresh()->recoveryCodes())->not->toContain($code); + $this->post('/logout'); + $this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge'); + $this->post('/two-factor-challenge', ['recovery_code' => $code])->assertSessionHasErrors(); + $this->assertGuest(); +}); + +test('two factor foundation rejects invalid codes and throttles repeated challenges', function () { + foundationMfaRoutes(); + $user = User::factory()->create(); + app(EnableTwoFactorAuthentication::class)($user); + $user->forceFill(['two_factor_confirmed_at' => now()])->save(); + $this->withSession(['login.id' => $user->id]); + for ($i = 0; $i < 5; $i++) { + $this->postJson('/two-factor-challenge', ['recovery_code' => 'invalid'])->assertUnprocessable(); + } + $this->postJson('/two-factor-challenge', ['recovery_code' => 'invalid'])->assertStatus(429); + $this->assertGuest(); +}); + +test('two factor foundation accepts an authenticator code without exposing its secret', function () { + foundationMfaRoutes(); + $user = User::factory()->create(); + app(EnableTwoFactorAuthentication::class)($user); + $user->forceFill(['two_factor_confirmed_at' => now()])->save(); + $this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge'); + $code = (new Google2FA)->getCurrentOtp(decrypt($user->two_factor_secret)); + $this->post('/two-factor-challenge', ['code' => $code])->assertRedirect(); + $this->assertAuthenticatedAs($user); +}); + +test('two factor foundation does not challenge an unfinished enrollment', function () { + foundationMfaRoutes(); + $user = User::factory()->create(); + app(EnableTwoFactorAuthentication::class)($user); + expect($user->hasEnabledTwoFactorAuthentication())->toBeFalse(); + $this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($user); +}); + +test('two factor foundation password confirmation uses the native password check and private view', function () { + $user = User::factory()->create(); + $this->actingAs($user)->get('/user/confirm-password')->assertOk() + ->assertInertia(fn (Assert $p) => $p->component('auth/confirm-password')); + $this->post('/user/confirm-password', ['password' => 'wrong'])->assertSessionHasErrors('password'); + $this->post('/user/confirm-password', ['password' => 'password'])->assertSessionHasNoErrors(); + expect(session('auth.password_confirmed_at'))->not->toBeNull(); +}); diff --git a/tests/Feature/AutomaticPurchasesTest.php b/tests/Feature/AutomaticPurchasesTest.php index 7b12fcb..f4e4f27 100644 --- a/tests/Feature/AutomaticPurchasesTest.php +++ b/tests/Feature/AutomaticPurchasesTest.php @@ -90,6 +90,47 @@ 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('supply task auto rule approval connects the reviewer to one requester draft while preserving human approvals', function () { + $planning = app(SupplyPlanning::class); + $this->actingAs($this->f['receiver']); + $planning->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy'])); + $this->actingAs($this->f['issuer']); + $draft = $planning->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key, + 'safety_milli' => 120000, 'minimum_stock_milli' => 120000, 'target_milli' => 140000, 'maximum_milli' => 160000, 'daily_milli' => 0, 'minimum_milli' => 0, 'pack_milli' => 1000])); + $this->actingAs($this->f['receiver']); + $policy = $planning->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft)); + $this->actingAs($this->f['issuer']); + $input = $this->input; + $input['data']['policy_key'] = $policy->record_key; + $rule = app(AutomaticPurchases::class)->save($this->f['issuer'], $input); + supplyGrant($this->f['issuer'], ['supply_review']); + $this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('rules.data.0.canApprove', false)); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.href', '/stock/reposicao?rule_id='.$rule->id.'#supply-rule-'.$rule->id)); + $this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('rules.total', 1)->where('rules.data.0.canApprove', true)); + $count = Requisition::query()->count(); + $approval = ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'data' => [], 'reason' => 'Conferência independente apenas no ensaio descartável.']; + $this->post('/stock/reposicao', $approval)->assertSessionHasNoErrors(); + $this->post('/stock/reposicao', $approval)->assertSessionHasNoErrors(); + $requisition = Requisition::query()->where('status', 'draft')->sole(); + expect(Requisition::query()->count())->toBe($count + 1)->and($requisition->requester_id)->toBe($this->f['issuer']->id) + ->and($requisition->items()->sole()->quantity)->toBe('20.000')->and($requisition->steps()->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('rules.data.0.state', 'approved')->where('rules.data.0.canApprove', false)); + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'prepare')->where('tasks.data.0.href', '/requisicoes/'.$requisition->id)); + expect($requisition->fresh()->status)->toBe('draft'); +}); + +test('supply task auto rule preserves the existing ability of an authorised preparer to suspend an active rule', function () { + $approved = activateAutomaticRule($this); + supplyGrant($this->f['issuer'], ['supply_review']); + $this->actingAs($this->f['issuer'])->get('/stock/reposicao?rule_id='.$approved->id)->assertInertia(fn (Assert $page) => $page->where('rules.data.0.canApprove', false)->where('rules.data.0.canSuspend', true)); + expect(app(AutomaticPurchases::class)->canReview($this->f['issuer'], $approved, 'suspend'))->toBeTrue(); + $this->post('/stock/reposicao', ['operation_key' => (string) Str::uuid(), 'record_key' => $approved->record_key, 'version' => $approved->version, 'action' => 'suspend', 'data' => [], 'reason' => 'Suspender a regra pelo percurso já existente.'])->assertSessionHasNoErrors(); + expect(app(SupplyPlanning::class)->latest('auto_rule')->sole()->state)->toBe('suspended'); +}); + test('durable signals survive the missing immediate callback and scheduled recovery never impersonates or duplicates drafts', function () { $id = pilotWork($this); $before = Requisition::query()->count(); diff --git a/tests/Feature/InternalRequisitionCandidateTest.php b/tests/Feature/InternalRequisitionCandidateTest.php index 5e2a130..79e785e 100644 --- a/tests/Feature/InternalRequisitionCandidateTest.php +++ b/tests/Feature/InternalRequisitionCandidateTest.php @@ -1,7 +1,9 @@ actingAs($this->actor); }); +test('requisition audit export includes every metadata record without private payloads or read side effects', function () { + $this->postJson('/requisicoes', $this->data)->assertRedirect(); + $r = Requisition::query()->latest('id')->firstOrFail(); + for ($i = 0; $i < 105; $i++) { + AuditLog::query()->create(['tenant_id' => $r->tenant_id, 'requisition_id' => $r->id, 'user_id' => $this->actor->id, + 'action' => $i === 0 ? "\t=FORMULA-TEST" : 'qa.metadata.'.$i, 'created_at' => now(), + 'before' => ['bank' => 'PRIVATE-BEFORE-MARKER'], 'after' => ['account_identifier' => 'PRIVATE-AFTER-MARKER', 'execution' => ['mode' => 'scheduled_replenishment']]]); + } + $expectedIds = AuditLog::query()->where('requisition_id', $r->id)->orderBy('id')->pluck('id')->all(); + $before = []; + foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) { + $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); + } + $response = $this->get('/requisicoes/'.$r->id.'/historico.csv')->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8') + ->assertHeader('Cache-Control', 'max-age=0, no-store, private'); + $csv = $response->streamedContent(); + expect($csv)->not->toContain('PRIVATE-BEFORE-MARKER')->not->toContain('PRIVATE-AFTER-MARKER')->toContain('scheduled_replenishment'); + $stream = fopen('php://temp', 'w+'); + fwrite($stream, $csv); + rewind($stream); + fgetcsv($stream, null, ';', '"', ''); + fgetcsv($stream, null, ';', '"', ''); + $ids = []; + $actions = []; + while (($row = fgetcsv($stream, null, ';', '"', '')) !== false) { + $ids[] = (int) $row[0]; + $actions[] = $row[2]; + expect($row)->toHaveCount(5); + } + fclose($stream); + expect($ids)->toBe($expectedIds)->and($actions[1])->toStartWith("'"); + $this->get('/requisicoes/'.$r->id)->assertInertia(fn (AssertableInertia $p) => $p + ->has('audit', 25)->where('auditPagination.total', count($expectedIds))->missing('audit.1.after')->where('audit.1.execution', 'scheduled_replenishment')); + $this->get('/requisicoes/'.$r->id.'?audit_page=2')->assertInertia(fn (AssertableInertia $p) => $p + ->has('audit', 25)->where('audit.0.id', $expectedIds[25])->where('auditPagination.current_page', 2)); + $this->get('/requisicoes/'.$r->id.'?audit_page=5')->assertInertia(fn (AssertableInertia $p) => $p + ->has('audit', 6)->where('audit.0.id', $expectedIds[100])->where('auditPagination.next_page_url', null)); + $this->getJson('/requisicoes/'.$r->id.'?audit_page=0')->assertUnprocessable(); + $this->getJson('/requisicoes/'.$r->id.'?audit_page=1000001')->assertUnprocessable(); + foreach ($before as $table => $rows) { + expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); + } +}); + +test('requisition audit export preserves requester department company and current access boundaries', function () { + $this->postJson('/requisicoes', $this->data)->assertRedirect(); + $r = Requisition::query()->latest('id')->firstOrFail(); + $url = '/requisicoes/'.$r->id.'/historico.csv'; + $other = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $r->department_id, 'purchasing_role' => PurchasingRole::Requester]); + $this->actingAs($other)->get($url)->assertNotFound(); + $department = Department::query()->create(['tenant_id' => $r->tenant_id, 'name' => 'Outro departamento sintético', 'code' => 'OTHER-AUDIT']); + $head = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + $this->actingAs($head)->get($url)->assertNotFound(); + $foreign = receiptFixture(); + $this->actingAs($foreign['issuer'])->get($url)->assertNotFound(); + $this->actingAs($this->actor); + require_once __DIR__.'/../Support/SupplyFixture.php'; + supplyGrant($this->actor, ['requester']); + DB::table('organization_memberships')->where('user_id', $this->actor->id)->update(['maintenance' => true]); + $this->get($url)->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->actor->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]); + $this->get($url)->assertForbidden(); +}); + test('autonomous adapter executes shared lifecycle with one request one reserve and independent versioned approval', function () { $count = Requisition::query()->count(); $this->postJson('/requisicoes', $this->data)->assertRedirect(); @@ -128,6 +194,57 @@ test('profile task catalogue covers the existing permissions without creating a ->where('workflowTasks.department_head.0.key', 'requisition.approve.department_head')); }); +test('notice consultation searches the full visible scope and reading preserves filters without deciding', function () { + $requests = []; + for ($i = 0; $i < 28; $i++) { + $this->postJson('/requisicoes', [...$this->data, 'operation_key' => (string) Str::uuid()])->assertRedirect(); + $r = Requisition::query()->latest('id')->firstOrFail(); + $this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect(); + $requests[] = $r->fresh(); + } + $old = $requests[0]; + $notice = DB::table('workflow_notices')->where('requisition_id', $old->id)->sole(); + $before = []; + foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'workflow_notices', 'audit_logs'] as $table) { + $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); + } + $this->actingAs($this->head)->get('/requisicoes/avisos?view=tasks&search=req-&page=2')->assertOk() + ->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (AssertableInertia $p) => $p + ->where('notices.total', 28)->has('notices.data', 3)->where('unread', 28) + ->where('notices.prev_page_url', fn ($url) => str_contains($url, 'search=req-') && str_contains($url, 'view=tasks'))); + $path = '/requisicoes/avisos?view=unread&search='.urlencode(mb_strtolower($old->number)).'&page=999'; + $this->get($path)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 1)->where('notices.current_page', 1) + ->where('notices.data.0.id', $notice->id)->where('unread', 28)); + $this->get('/requisicoes/avisos?search='.urlencode("' OR 1=1 --"))->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)->where('unread', 28)); + foreach ($before as $table => $rows) { + expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); + } + $filters = ['view' => 'unread', 'page' => 2, 'search' => $old->number]; + $redirect = '/requisicoes/avisos?'.http_build_query($filters); + $this->post('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertRedirect($redirect); + $this->post('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertRedirect($redirect); + $this->get($redirect)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)->where('notices.current_page', 1)->where('unread', 27)); + foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements'] as $table) { + expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($before[$table]); + } + expect(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(1); + $this->actingAs($this->actor)->get($path)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)); +}); + +test('notice consultation rejects invalid context before recording a read', function () { + $this->postJson('/requisicoes', $this->data)->assertRedirect(); + $r = Requisition::query()->latest('id')->firstOrFail(); + $this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect(); + $notice = DB::table('workflow_notices')->where('requisition_id', $r->id)->sole(); + $this->actingAs($this->head); + foreach ([['search' => str_repeat('A', 101)], ['page' => 0], ['view' => 'invalid']] as $filters) { + $this->getJson('/requisicoes/avisos?'.http_build_query($filters))->assertUnprocessable(); + $this->postJson('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertUnprocessable(); + } + expect(DB::table('workflow_notices')->where('id', $notice->id)->value('read_at'))->toBeNull(); + expect(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(0); +}); + test('internal notices are created once with submission and advance only after a valid decision', function () { $this->f['tenant']->update(['first_limit_minor' => 1, 'second_limit_minor' => 2]); $buyer = User::factory()->create(['tenant_id' => $this->actor->tenant_id, 'department_id' => $this->actor->department_id, 'purchasing_role' => PurchasingRole::Procurement]); diff --git a/tests/Feature/InventoryCorrectionAccessTest.php b/tests/Feature/InventoryCorrectionAccessTest.php new file mode 100644 index 0000000..c784058 --- /dev/null +++ b/tests/Feature/InventoryCorrectionAccessTest.php @@ -0,0 +1,42 @@ +f = receiptFixture(); + supplyGrant($this->f['receiver'], ['inventory_disposition_prepare', 'inventory_reversal_prepare']); + $this->actingAs($this->f['receiver']); +}); + +test('correction source access rechecks expired and maintenance profiles without writing', function (string $mode) { + $service = app(InventoryCorrectionPage::class); + $call = fn () => match ($mode) { + 'receipts' => $service->receipts(collect()), + 'operations' => $service->operations(collect()), + default => $service->read(Request::create('/stock/estornos'), $this->f['receiver'], 'stock_reversal'), + }; + $call(); + $before = DB::table('audit_logs')->count(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true]); + expect($call)->toThrow(HttpException::class); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]); + expect($call)->toThrow(HttpException::class); + expect(DB::table('audit_logs')->count())->toBe($before); +})->with(['receipts', 'operations', 'records']); + +test('correction read permission does not grant preparation source access', function () { + $viewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id]); + supplyGrant($viewer, ['inventory_disposition_read', 'inventory_reversal_read']); + $this->actingAs($viewer); + $service = app(InventoryCorrectionPage::class); + expect($service->read(Request::create('/stock/estornos'), $viewer, 'stock_reversal')['ready'])->toBeTrue(); + expect(fn () => $service->receipts(collect()))->toThrow(HttpException::class); + expect(fn () => $service->operations(collect()))->toThrow(HttpException::class); +}); diff --git a/tests/Feature/InventoryCorrectionPaginationTest.php b/tests/Feature/InventoryCorrectionPaginationTest.php new file mode 100644 index 0000000..6246572 --- /dev/null +++ b/tests/Feature/InventoryCorrectionPaginationTest.php @@ -0,0 +1,161 @@ +f = receiptFixture('100.000'); + supplyGrant($this->f['receiver'], ['warehouse', 'inventory_disposition_prepare', 'inventory_reversal_prepare']); + supplyGrant($this->f['issuer'], ['inventory_disposition_review', 'inventory_reversal_review']); + $this->actingAs($this->f['receiver']); + $this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000')); + $this->receiptItem = $this->receipt->items->first(); +}); + +function correctionPageDraft(object $test, string $kind): SupplyRecord +{ + $workflow = $kind === 'stock_event' ? app(InventoryDispositions::class) : app(InventoryReversals::class); + $data = $kind === 'stock_event' ? ['kind' => 'supplier_return', 'stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id, + 'receipt_item_id' => $test->receiptItem->id, 'quantity' => '1.000'] : ['source_kind' => 'receipt', 'receipt_item_id' => $test->receiptItem->id]; + + return $workflow->save($test->f['receiver'], supplyInput(['reference' => 'FILTER-MATCH-ROOT', ...$data])); +} + +test('correction pagination reaches old pending processes without changing approvals stock or history', function (string $kind, string $path) { + $first = correctionPageDraft($this, $kind); + $beforeRecord = $first->fresh()->getAttributes(); + for ($i = 0; $i < 209; $i++) { + $copy = $first->replicate(); + $copy->record_key = (string) Str::uuid(); + $copy->operation_key = (string) Str::uuid(); + $copy->data = [...$copy->data, 'reference' => 'ARCHIVE-'.$i]; + $copy->created_at = $first->created_at; + $copy->save(); + } + $before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('budget_entries')->count()]; + $this->actingAs($this->f['issuer'])->get($path)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 210)->has('records', 25)); + $this->get($path.'?records_page=9')->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page + ->has('records', 10)->where('records.9.id', $first->id)->where('records.9.canApprove', true)); + $this->get($path.'?record_id='.$first->id.'&records_page=9')->assertInertia(fn (Assert $page) => $page->where('recordPagination', null) + ->has('records', 1)->where('records.0.id', $first->id)->has('history.'.$first->record_key, 1)); + expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('budget_entries')->count()])->toBe($before) + ->and($first->fresh()->getAttributes())->toBe($beforeRecord); +})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]); + +test('correction pagination filters only current versions and direct links preserve current independent actions', function (string $kind, string $path) { + $first = correctionPageDraft($this, $kind); + $workflow = $kind === 'stock_event' ? app(InventoryDispositions::class) : app(InventoryReversals::class); + $data = [...$first->data, 'reference' => 'CURRENT-REFERENCE']; + if ($kind === 'stock_event') { + $data['quantity'] = '1.000'; + } + $updated = $workflow->save($this->f['receiver'], supplyInput($data, 'save', $first)); + $this->actingAs($this->f['issuer']); + $approved = $workflow->save($this->f['issuer'], supplyInput([], 'approve', $updated)); + $before = [$first->fresh()->getAttributes(), $approved->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()]; + $this->get($path.'?search=filter-match-root')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0)->has('records', 0)); + $this->get($path.'?search=current-reference&state=approved')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 1) + ->where('records.0.id', $approved->id)->has('history.'.$first->record_key, 3)); + $this->get($path.'?search=current-reference&state=draft')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0)); + $this->actingAs($this->f['receiver'])->get($path.'?record_id='.$first->id.'&search=NO-MATCH&state=executed')->assertInertia(fn (Assert $page) => $page + ->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canExecute', true)->where('recordPagination', null)); + expect([$first->fresh()->getAttributes(), $approved->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()])->toBe($before); +})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]); + +test('correction pagination isolates companies and validates pages states reference and focus type', function (string $kind, string $path) { + $first = correctionPageDraft($this, $kind); + foreach (['records_page=0', 'records_page=1000001', 'state=unknown', 'search='.str_repeat('A', 101)] as $query) { + $this->getJson($path.'?'.$query)->assertUnprocessable(); + } + $wrongPath = $kind === 'stock_event' ? '/stock/estornos' : '/stock/ocorrencias'; + $this->getJson($wrongPath.'?record_id='.$first->id)->assertUnprocessable(); + $tenant = Tenant::query()->create(['name' => 'Empresa isolada de correcções']); + $foreign = User::factory()->create(['tenant_id' => $tenant->id]); + supplyGrant($foreign, ['inventory_disposition_read', 'inventory_reversal_read']); + $this->actingAs($foreign)->get($path)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0)->has('records', 0)); + $this->getJson($path.'?record_id='.$first->id)->assertUnprocessable(); +})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]); + +test('correction pagination preserves maintenance and membership expiry boundaries', function (string $kind, string $path) { + correctionPageDraft($this, $kind); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->get($path)->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + $this->get($path)->assertForbidden(); +})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]); + +test('correction pagination preserves an older selected receipt and excludes it after a native reversal', function () { + $draft = correctionPageDraft($this, 'stock_event'); + for ($i = 0; $i < 200; $i++) { + app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '0.001')); + } + $before = [$draft->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()]; + expect(app(InventoryCorrectionPage::class)->receipts(collect(), mb_strtolower($this->receipt->number))->sole()->id)->toBe($this->receiptItem->id); + $this->get('/stock/ocorrencias?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('receipts', 201) + ->where('receipts', fn ($rows) => collect($rows)->where('id', $this->receiptItem->id)->count() === 1)); + expect([$draft->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($before); + $workflow = app(InventoryReversals::class); + $correction = $workflow->save($this->f['receiver'], supplyInput(['reference' => 'CORRECT-OLD-RECEIPT', 'source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id])); + $this->actingAs($this->f['issuer']); + $approved = $workflow->save($this->f['issuer'], supplyInput([], 'approve', $correction)); + $this->actingAs($this->f['receiver']); + $workflow->save($this->f['receiver'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Correcção fictícia para validar a origem antiga.'], 'execute', $approved)); + $this->get('/stock/ocorrencias?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('receipts', 200) + ->where('receipts', fn ($rows) => collect($rows)->where('id', $this->receiptItem->id)->isEmpty())); + $this->get('/stock/ocorrencias?source_search='.urlencode($this->receipt->number).'&selected_receipt_id='.$this->receiptItem->id)->assertInertia(fn (Assert $page) => $page->has('receipts', 0)); + expect($draft->fresh()->getAttributes())->toBe($before[0])->and($this->receiptItem->fresh()->accepted_milli)->toBe(10000); +}); + +test('correction pagination preserves an older selected operation without reintroducing an executed correction', function () { + $issue = function (string $quantity) { + return app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'issue', 'reference' => 'OLD-ISSUE-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), + 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, + 'quantity' => $quantity, 'reason' => 'Saída fictícia para testar a escolha de origens antigas.']); + }; + $old = $issue('1.000'); + $workflow = app(InventoryReversals::class); + $draft = $workflow->save($this->f['receiver'], supplyInput(['reference' => 'CORRECT-OLD-ISSUE', 'source_kind' => 'operation', 'source_operation_id' => $old->id])); + for ($i = 0; $i < 200; $i++) { + $issue('0.001'); + } + $before = [$draft->fresh()->getAttributes(), $old->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()]; + expect(app(InventoryCorrectionPage::class)->operations(collect(), mb_strtolower($old->reference))->sole()->id)->toBe($old->id); + $this->get('/stock/estornos?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('operations', 201) + ->where('operations', fn ($rows) => collect($rows)->where('id', $old->id)->count() === 1)); + expect([$draft->fresh()->getAttributes(), $old->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($before); + $updated = $workflow->save($this->f['receiver'], supplyInput($draft->data, 'save', $draft)); + $this->actingAs($this->f['issuer']); + $approved = $workflow->save($this->f['issuer'], supplyInput([], 'approve', $updated)); + $this->actingAs($this->f['receiver']); + $executed = $workflow->save($this->f['receiver'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Correcção fictícia conferida sobre a saída antiga.'], 'execute', $approved)); + $this->get('/stock/estornos?record_id='.$executed->id)->assertInertia(fn (Assert $page) => $page->has('operations', 200) + ->where('operations', fn ($rows) => collect($rows)->where('id', $old->id)->isEmpty())); + $this->get('/stock/estornos?source_search='.urlencode($old->reference).'&selected_operation_id='.$old->id)->assertInertia(fn (Assert $page) => $page->has('operations', 0)); + expect($old->fresh()->getAttributes())->toBe($before[1]); +}); + +test('correction source search preserves eligible selections and validates company and access', function () { + $page = app(InventoryCorrectionPage::class); + expect($page->receipts(collect(), 'NO-MATCH-FICTICIO'))->toHaveCount(0); + $this->get('/stock/ocorrencias?source_search=NO-MATCH-FICTICIO&selected_receipt_id='.$this->receiptItem->id)->assertInertia(fn (Assert $p) => $p->has('receipts', 1)->where('receipts.0.id', $this->receiptItem->id)); + $this->getJson('/stock/estornos?source_search='.str_repeat('A', 101))->assertUnprocessable(); + $this->get('/stock/estornos?source_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('receipts', 0)->has('operations', 0)); + $tenant = Tenant::query()->create(['name' => 'Empresa de pesquisa isolada']); + $foreign = User::factory()->create(['tenant_id' => $tenant->id]); + supplyGrant($foreign, ['inventory_disposition_prepare', 'inventory_reversal_prepare']); + $this->actingAs($foreign)->getJson('/stock/ocorrencias?selected_receipt_id='.$this->receiptItem->id)->assertUnprocessable(); + supplyGrant($this->f['otherReceiver'], ['inventory_disposition_read', 'inventory_reversal_read']); + $this->actingAs($this->f['otherReceiver'])->get('/stock/estornos?source_search=TEST')->assertInertia(fn (Assert $p) => $p->where('capabilities.prepare', false)->has('receipts', 0)->has('operations', 0)); +}); diff --git a/tests/Feature/InventoryDispositionsTest.php b/tests/Feature/InventoryDispositionsTest.php index e585fba..d2ee4ad 100644 --- a/tests/Feature/InventoryDispositionsTest.php +++ b/tests/Feature/InventoryDispositionsTest.php @@ -13,8 +13,10 @@ use App\Services\ReceiptWorkflow; use App\Services\RequisitionWorkflow; use App\Services\StockWorkflow; use App\Services\SupplyPlanning; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Inertia\Testing\AssertableInertia as Assert; require_once __DIR__.'/../Support/ReceiptFixture.php'; require_once __DIR__.'/../Support/SupplyFixture.php'; @@ -189,3 +191,83 @@ test('HTTP menu and commands use the same functional permissions without automat $this->postJson('/stock/ocorrencias', supplyInput(dispositionData($this)))->assertForbidden(); expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('executed'); }); +test('disposition task connects independent review and physical execution once without financial documents', function () { + $draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this))); + $path = '/stock/ocorrencias?record_id='.$draft->id; + supplyGrant($this->f['receiver'], ['inventory_disposition_review']); + supplyGrant($this->f['issuer'], ['warehouse']); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false)); + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.task', 'disposition_approve')->where('tasks.data.0.href', $path.'#disposition-'.$draft->id)->missing('tasks.data.0.snapshot')); + $before = [SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), Invoice::query()->count()]; + $this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('focusChanged', false)->where('records.0.canApprove', true)); + expect([SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), Invoice::query()->count()])->toBe($before); + $approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft)); + expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canExecute', false)->where('records.0.canReturn', true)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'disposition_execute')); + $command = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Saída física confirmada no ensaio descartável.'], 'execute', $approved); + $this->postJson('/stock/ocorrencias', $command)->assertRedirect(); + $this->postJson('/stock/ocorrencias', $command)->assertRedirect(); + expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(7000) + ->and(Invoice::query()->count())->toBe($before[3])->and(StockMovement::query()->count())->toBe($before[1] + 1); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); +}); + +test('disposition task keeps changed stock sources pending for return without permitting silent execution', function () { + $approved = approvedDisposition($this, ['kind' => 'write_off']); + $this->f['stock']->update(['name' => 'Identificação revista depois da autorização']); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.blocked', true)); + $this->postJson('/stock/ocorrencias', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'A saída não pode ignorar alteração da origem.'], 'execute', $approved))->assertUnprocessable(); + expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000); + $this->actingAs($this->f['issuer']); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'disposition_return')->where('tasks.data.0.blocked', true)); + $returned = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'return', $approved)); + expect($returned->state)->toBe('returned')->and($returned->snapshot['source'])->toBe($approved->snapshot['source']); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); +}); + +test('disposition task excludes every preparer while retaining the separately authorised physical executor', function () { + $draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this))); + supplyGrant($this->f['issuer'], ['inventory_disposition_prepare']); + $this->actingAs($this->f['issuer']); + $updated = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput(dispositionData($this), 'save', $draft)); + supplyGrant($this->f['receiver'], ['inventory_disposition_review']); + foreach ([$this->f['issuer'], $this->f['receiver']] as $person) { + expect(app(InventoryDispositions::class)->canAct($person, $updated, 'approve'))->toBeFalse(); + } + supplyGrant($this->f['otherReceiver'], ['inventory_disposition_review']); + $this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); + $approved = app(InventoryDispositions::class)->save($this->f['otherReceiver'], supplyInput([], 'approve', $updated)); + expect(app(InventoryDispositions::class)->canAct($this->f['otherReceiver'], $approved, 'execute'))->toBeFalse() + ->and(app(InventoryDispositions::class)->canAct($this->f['receiver'], $approved, 'execute'))->toBeTrue(); +}); + +test('disposition task preserves oldest pending events beyond the initial display cap', function () { + $first = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this))); + for ($index = 1; $index < 105; $index++) { + app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this))); + } + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25) + ->where('tasks.data.0.id', 'disposition:'.$first->id.':approve')); + $this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); + $this->get('/stock/ocorrencias')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 105)); + $this->get('/stock/ocorrencias?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)->where('records.0.canApprove', true)); +}); + +test('disposition task preserves company isolation maintenance and expired assignments', function () { + $draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this))); + $path = '/stock/ocorrencias?record_id='.$draft->id; + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['warehouse']); + $this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable(); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertForbidden(); + expect(app(InventoryDispositions::class)->canAct($this->f['issuer'], $draft, 'approve'))->toBeFalse(); + DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden(); +}); diff --git a/tests/Feature/InventoryLotsTest.php b/tests/Feature/InventoryLotsTest.php index f9e7f8f..81012e2 100644 --- a/tests/Feature/InventoryLotsTest.php +++ b/tests/Feature/InventoryLotsTest.php @@ -1,17 +1,21 @@ context($test->f['stock']->id, $test->f['warehouse']->id); } +function lotQualityTasks(object $test, User $actor): array +{ + $test->actingAs($actor); + + return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), fn ($row) => str_starts_with($row['id'], 'lot-quality:'))); +} + function lotDecision($test, array $lot, string $action = 'release', $actor = null, ?int $warehouse = null): SupplyRecord { $actor ??= $test->f['receiver']; @@ -332,3 +343,270 @@ test('later identification corrections preserve the metadata actually used in th ->and(lotContext($this)['usable_milli'])->toBe(0) ->and(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(0); }); +function taskLotDraft($test, array $extra = [], ?SupplyRecord $previous = null): SupplyRecord +{ + $test->actingAs($test->f['issuer']); + + return app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id, + 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [], ...$extra], 'prepare', $previous)); +} + +test('lot setup task connects independent activation without changing physical stock or releasing quality', function () { + $draft = taskLotDraft($this, ['lots' => [['code' => 'TASK-LOT', 'expires_on' => today()->addDays(20)->toDateString()]]]); + supplyGrant($this->f['issuer'], ['inventory_lot_review']); + $path = '/stock/lotes?record_id='.$draft->id; + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canActivate', false)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.task', 'lot_setup_activate')->where('tasks.data.0.href', $path.'#lot-setup-'.$draft->id)->missing('tasks.data.0.snapshot')); + $before = [SupplyRecord::query()->count(), StockMovement::query()->count(), InventoryLotAllocation::query()->count(), DB::table('audit_logs')->count()]; + $this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canActivate', true)); + expect([SupplyRecord::query()->count(), StockMovement::query()->count(), InventoryLotAllocation::query()->count(), DB::table('audit_logs')->count()])->toBe($before); + $command = supplyInput([], 'activate', $draft); + $this->postJson('/stock/lotes', $command)->assertRedirect(); + $this->postJson('/stock/lotes', $command)->assertRedirect(); + $context = lotContext($this); + expect($context['physical']['quantity'])->toBe(10000)->and($context['usable_milli'])->toBe(0)->and($context['lots'][0]['state'])->toBe('held') + ->and(StockMovement::query()->count())->toBe($before[1])->and(InventoryLotAllocation::query()->count())->toBe(1); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'lot_release')); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.state', 'active')->where('records.0.canActivate', false)); +}); + +test('lot setup task rejects a stale balance and reuses the revised preparation at the old link', function () { + $draft = taskLotDraft($this); + lotIssue($this, '1.000'); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); + $this->postJson('/stock/lotes', supplyInput([], 'activate', $draft))->assertUnprocessable(); + expect(InventoryLotAllocation::query()->count())->toBe(0); + $updated = taskLotDraft($this, [], $draft); + $this->actingAs($this->f['receiver'])->get('/stock/lotes?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $updated->id)); + $this->postJson('/stock/lotes', supplyInput([], 'activate', $updated))->assertRedirect(); + expect(lotContext($this)['physical']['quantity'])->toBe(9000); +}); + +test('lot setup task preserves active configuration while an independently prepared revision awaits review', function () { + $active = lotSetup($this, [['code' => 'BASE-TASK', 'expires_on' => today()->addDays(20)->toDateString()]]); + $counts = [StockMovement::query()->count(), InventoryLotAllocation::query()->count()]; + $this->actingAs($this->f['otherReceiver']); + $draft = app(InventoryLots::class)->save($this->f['otherReceiver'], supplyInput([...$active->data, 'minimum_remaining_days' => 5, 'lots' => []], 'prepare', $active)); + supplyGrant($this->f['issuer'], ['inventory_lot_review']); + supplyGrant($this->f['otherReceiver'], ['inventory_lot_review']); + expect(app(InventoryLots::class)->canActivate($this->f['issuer'], $draft))->toBeFalse() + ->and(app(InventoryLots::class)->canActivate($this->f['otherReceiver'], $draft))->toBeFalse(); + $this->actingAs($this->f['receiver'])->get('/stock/lotes?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('records', 2) + ->where('records.0.id', $draft->id)->where('records.1.id', $active->id)->where('contexts.'.$active->record_key.'.setup_id', $active->id)); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2) + ->where('tasks.data', fn ($rows) => collect($rows)->pluck('task')->sort()->values()->all() === ['lot_release', 'lot_setup_activate'])); + $this->postJson('/stock/lotes', supplyInput([], 'activate', $draft))->assertRedirect(); + expect([StockMovement::query()->count(), InventoryLotAllocation::query()->count()])->toBe($counts) + ->and($active->fresh()->data['minimum_remaining_days'])->toBe(0)->and(app(InventoryLots::class)->setup($active->stock_item_id, $active->warehouse_id)->data['minimum_remaining_days'])->toBe(5); +}); + +test('lot setup task retains older pending setups past the overview limit', function () { + $first = taskLotDraft($this); + for ($index = 1; $index < 105; $index++) { + taskLotDraft($this); + } + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25) + ->where('tasks.data.0.id', 'lot-setup:'.$first->id.':activate')); + $this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); + $this->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('controlPagination.total', 105)); + $this->get('/stock/lotes?controls_page=5')->assertInertia(fn (Assert $page) => $page->has('records', 5)->where('records.4.id', $first->id)); + $this->get('/stock/lotes?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)); +}); + +test('lot setup task preserves company maintenance and expiry boundaries', function () { + $draft = taskLotDraft($this); + $path = '/stock/lotes?record_id='.$draft->id; + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['inventory_lot_review']); + $this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable(); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertForbidden(); + expect(app(InventoryLots::class)->canActivate($this->f['receiver'], $draft))->toBeFalse(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden(); +}); +test('lot setup task does not keep an obsolete competing preparation actionable after another control is activated', function () { + $older = taskLotDraft($this); + $winner = taskLotDraft($this); + $this->actingAs($this->f['receiver'])->postJson('/stock/lotes', supplyInput([], 'activate', $winner))->assertRedirect(); + $this->postJson('/stock/lotes', supplyInput([], 'activate', $older))->assertUnprocessable(); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get('/stock/lotes?record_id='.$older->id)->assertInertia(fn (Assert $page) => $page->where('records.0.canActivate', false)); + expect($older->fresh()->state)->toBe('draft')->and(app(InventoryLots::class)->setup($older->stock_item_id, $older->warehouse_id)->record_key)->toBe($winner->record_key); +}); + +test('lot quality task connects identification and independent release without changing physical stock', function () { + lotSetup($this); + $lot = lotContext($this)['lots'][0]; + $tasks = lotQualityTasks($this, $this->f['issuer']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_identify')->and($tasks[0]['due_on'])->toBeNull(); + $this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('focusLotId', $lot['id'])->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.canIdentify', true)); + $metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']); + $before = $metadata->getAttributes(); + app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'IDENTIFIED-QA', 'expires_on' => today()->addDays(10)->toDateString()], 'identify', $metadata)); + expect(lotQualityTasks($this, $this->f['issuer']))->toBe([]); + $tasks = lotQualityTasks($this, $this->f['receiver']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release'); + lotDecision($this, lotContext($this)['lots'][0]); + expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['usable_milli'])->toBe(10000) + ->and(lotContext($this)['physical']['quantity'])->toBe(10000)->and($metadata->fresh()->getAttributes())->toBe($before); +}); + +test('lot quality task excludes receipt contributors even when release permission is accumulated', function () { + lotSetup($this, [['code' => 'BASE', 'expires_on' => today()->addDays(10)->toDateString()]]); + lotDecision($this, lotContext($this)['lots'][0]); + $data = receiptPayload($this->f, '2.000'); + $data['items'][0]['lots'] = [['code' => 'RECEIVED-QA', 'expires_on' => today()->addDays(10)->toDateString()]]; + app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data); + expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]); + $tasks = lotQualityTasks($this, $this->f['otherReceiver']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release'); + $lot = collect(lotContext($this)['lots'])->firstWhere('data.code', 'RECEIVED-QA'); + lotDecision($this, $lot, 'release', $this->f['otherReceiver']); + expect(lotQualityTasks($this, $this->f['otherReceiver']))->toBe([]); +}); + +test('lot quality task detects expiry without movement and requests explicit retention only for prior release', function () { + lotSetup($this, [['code' => 'EXPIRY-QA', 'expires_on' => today()->addDay()->toDateString()]]); + $release = lotDecision($this, lotContext($this)['lots'][0]); + $before = SupplyRecord::query()->count(); + $this->travel(2)->days(); + $tasks = lotQualityTasks($this, $this->f['receiver']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_hold') + ->and(SupplyRecord::query()->count())->toBe($before)->and(lotContext($this)['usable_milli'])->toBe(0) + ->and(lotContext($this)['physical']['quantity'])->toBe(10000)->and($release->fresh()->state)->toBe('released'); + $context = lotContext($this); + $this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.canHold', true)); + lotDecision($this, $context['lots'][0], 'hold'); + expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and($release->fresh()->state)->toBe('released'); +}); + +test('lot quality task reappears after metadata correction and stale metadata cannot decide', function () { + lotSetup($this, [['code' => 'BEFORE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]); + $entry = lotContext($this)['lots'][0]; + $release = lotDecision($this, $entry); + $metadata = SupplyRecord::query()->findOrFail($entry['metadata_id']); + $this->actingAs($this->f['issuer']); + app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'AFTER-QA', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', $metadata)); + $tasks = lotQualityTasks($this, $this->f['receiver']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release') + ->and(app(InventoryLots::class)->canIdentify($this->f['issuer'], $metadata))->toBeFalse() + ->and(app(InventoryLots::class)->canDecideLot($this->f['receiver'], $metadata, lotContext($this), $entry, 'release'))->toBeFalse() + ->and($release->fresh()->data['metadata_id'])->toBe($metadata->id); + $this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.data.code', 'AFTER-QA')); +}); + +test('lot quality task enforces company warehouse maintenance and membership expiry', function () { + $setup = lotSetup($this, [['code' => 'SCOPE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]); + $entry = lotContext($this)['lots'][0]; + $path = '/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id; + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['inventory_lot_release']); + expect(lotQualityTasks($this, $foreign['receiver']))->toBe([]); + $this->getJson($path)->assertUnprocessable(); + $this->actingAs($this->f['receiver'])->getJson('/stock/lotes?lot_id='.$entry['id'])->assertUnprocessable(); + $this->getJson($path.'&record_id='.$setup->id)->assertUnprocessable(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]); + $this->postJson('/stock/lotes', supplyInput(['lot_id' => $entry['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $entry['state_version']], 'release', SupplyRecord::query()->findOrFail($entry['metadata_id'])))->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]); + expect(lotContext($this)['usable_milli'])->toBe(0); +}); + +test('lot quality task does not offer release against an unreconciled partition', function () { + $setup = lotSetup($this, [['code' => 'BALANCE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]); + $context = lotContext($this); + $entry = $context['lots'][0]; + $context['reconciled'] = false; + $this->partialMock(InventoryLots::class)->shouldReceive('context')->andReturn($context); + expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['reconciled'])->toBeFalse(); + $this->get('/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id)->assertInertia(fn (Assert $page) => $page->where('contexts.'.$setup->record_key.'.lots.0.canRelease', false)); +}); + +test('lot quality task retains over one hundred lots with paginated tasks and exact links', function () { + $entries = array_map(fn ($i) => ['code' => 'VOLUME-'.$i, 'quantity' => '0.100', 'expires_on' => today()->addDays(10)->toDateString()], range(1, 100)); + lotSetup($this, $entries); + $data = receiptPayload($this->f, '5.000'); + $data['items'][0]['lots'] = array_map(fn ($i) => ['code' => 'NEW-'.$i, 'quantity' => '1.000', 'expires_on' => today()->addDays(10)->toDateString()], range(1, 5)); + app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data); + $tasks = lotQualityTasks($this, $this->f['otherReceiver']); + expect($tasks)->toHaveCount(105); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)); + $this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->has('tasks.data', 5)); + $before = SupplyRecord::query()->count(); + $this->get($tasks[104]['href'])->assertOk()->assertInertia(fn (Assert $page) => $page->where('focusLotId', lotContext($this)['lots'][104]['id'])); + expect(SupplyRecord::query()->count())->toBe($before)->and(lotContext($this)['physical']['quantity'])->toBe(15000); +}); + +test('lot quality task preserves the approved receipt quarantine policy and omits empty lots', function () { + lotSetup($this, [['code' => 'POLICY-BASE', 'expires_on' => today()->addDays(10)->toDateString()]], ['quarantine_on_receipt' => false]); + lotDecision($this, lotContext($this)['lots'][0]); + $data = receiptPayload($this->f, '2.000'); + $data['items'][0]['lots'] = [['code' => 'POLICY-NEW', 'expires_on' => today()->addDays(10)->toDateString()]]; + app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data); + $entry = collect(lotContext($this)['lots'])->firstWhere('data.code', 'POLICY-NEW'); + expect($entry['quality_released'])->toBeTrue()->and($entry['release_mode'])->toBe('receipt_policy') + ->and(lotQualityTasks($this, $this->f['otherReceiver']))->toBe([]); + lotIssue($this, '12.000'); + $this->actingAs($this->f['issuer']); + app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'EMPTY-CORRECTED', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($entry['metadata_id']))); + expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['physical']['quantity'])->toBe(0); +}); + +test('lot pagination reaches old controls and history beyond the former fixed limits without writes', function () { + $first = taskLotDraft($this); + for ($i = 0; $i < 209; $i++) { + $copy = $first->replicate(); + $copy->record_key = (string) Str::uuid(); + $copy->operation_key = (string) Str::uuid(); + $copy->created_at = $first->created_at; + $copy->save(); + } + $before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), StockMovement::query()->count()]; + $this->actingAs($this->f['receiver'])->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 210) + ->where('historyPagination.total', 210)->has('records', 25)->has('history', 50)); + $this->get('/stock/lotes?controls_page=9&history_page=5')->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page + ->where('controlPagination.current_page', 9)->where('historyPagination.current_page', 5)->has('records', 10)->has('history', 10) + ->where('records.9.id', $first->id)->where('history.9.id', $first->id) + ->where('controlPagination.prev_page_url', fn ($url) => str_contains($url, 'history_page=5')) + ->where('historyPagination.prev_page_url', fn ($url) => str_contains($url, 'controls_page=9'))); + $this->get('/stock/lotes?record_id='.$first->id.'&history_page=5')->assertInertia(fn (Assert $page) => $page->where('controlPagination', null) + ->has('records', 1)->where('records.0.id', $first->id)->where('historyPagination.total', 210)); + expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), StockMovement::query()->count()])->toBe($before); +}); + +test('lot pagination keeps active control and pending revision together at the page boundary and exact lot focus', function () { + $active = lotSetup($this, [['code' => 'PAGE-BASE', 'expires_on' => today()->addDays(20)->toDateString()]]); + $entry = lotContext($this)['lots'][0]; + $before = $active->fresh()->getAttributes(); + $this->actingAs($this->f['otherReceiver']); + $draft = app(InventoryLots::class)->save($this->f['otherReceiver'], supplyInput([...$active->data, 'minimum_remaining_days' => 5, 'lots' => []], 'prepare', $active)); + $this->actingAs($this->f['issuer']); + for ($i = 0; $i < 25; $i++) { + $item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'PAGE-'.$i, 'name' => 'Artigo sintético sem saldo '.$i, 'unit' => 'cx']); + app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $item->id, 'warehouse_id' => $this->f['warehouse']->id, + 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare')); + } + $this->actingAs($this->f['receiver'])->get('/stock/lotes?controls_page=2')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 26) + ->has('records', 2)->where('records.0.id', $draft->id)->where('records.1.id', $active->id) + ->where('contexts.'.$active->record_key.'.setup_id', $active->id)); + $this->get('/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id)->assertInertia(fn (Assert $page) => $page->where('controlPagination', null) + ->has('records', 2)->where('focusLotId', $entry['id'])->where('contexts.'.$active->record_key.'.setup_id', $active->id)); + expect($active->fresh()->getAttributes())->toBe($before); +}); + +test('lot pagination rejects invalid pages and preserves tenant history boundaries', function () { + taskLotDraft($this); + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['inventory_lot_read']); + $this->actingAs($foreign['receiver'])->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 0)->where('historyPagination.total', 0)); + $this->actingAs($this->f['receiver']); + foreach (['controls_page=0', 'history_page=-1', 'controls_page=abc', 'history_page=1000001'] as $query) { + $this->getJson('/stock/lotes?'.$query)->assertUnprocessable(); + } +}); diff --git a/tests/Feature/InventoryReversalsTest.php b/tests/Feature/InventoryReversalsTest.php index e41971f..0f3a3c9 100644 --- a/tests/Feature/InventoryReversalsTest.php +++ b/tests/Feature/InventoryReversalsTest.php @@ -16,8 +16,11 @@ use App\Services\StockWorkflow; use App\Services\SupplyCoordination; use App\Services\SupplyHistory; use App\Services\SupplyPlanning; +use App\Services\WorkflowTaskInbox; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Inertia\Testing\AssertableInertia as Assert; require_once __DIR__.'/../Support/ReceiptFixture.php'; require_once __DIR__.'/../Support/SupplyFixture.php'; @@ -91,12 +94,17 @@ test('full issue reversal preserves the original and is exactly once even on an expect(fn () => approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]))->toThrow(ValidationException::class); }); -test('a corrected issue no longer satisfies a reservation and the same need can use a new actual issue', function () { +test('coordination task returns when a corrected issue no longer satisfies a reservation and the same need can use a new actual issue', function () { $reservation = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'quantity_milli' => 3000, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => 'NEED-REV'])); $issue = reversalStock($this); $fulfilled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 3000], 'fulfil', $reservation)); + $pending = fn () => collect(app(WorkflowTaskInbox::class)->build($this->f['receiver'], 'procurement'))->where('task', 'reservation_followup')->count(); + expect($pending())->toBe(0); reverseSourceOperation($this, $issue); + expect($pending())->toBe(1)->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $fulfilled, 'fulfil'))->toBeTrue(); + $this->get('/compras/fornecimento?view=reservations&issue_record_id='.$fulfilled->id.'&selected_issue_id='.$issue->id) + ->assertInertia(fn (Assert $page) => $page->has('operations', 0)); $events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString()); expect($events['reservations'][0]['quantity'])->toBe(3000)->and($events['fulfilled_needs'])->toBe([])->and($events['consumed_today'])->toBe(0) ->and($fulfilled->fresh()->state)->toBe('fulfilled')->and($fulfilled->data['applications'])->toHaveCount(1); @@ -106,6 +114,7 @@ test('a corrected issue no longer satisfies a reservation and the same need can $again = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $nextIssue->id, 'quantity_milli' => 3000], 'fulfil', $fulfilled)); expect(app(SupplyCoordination::class)->remainingReservation($again))->toBe(0)->and($again->data['applications'])->toHaveCount(2); expect($again->snapshot['effective_allocated_milli'])->toBe(3000)->and($again->snapshot['reversed_operations'])->toContain($issue->id); + expect($pending())->toBe(0); }); test('a reversal outside a closed history period invalidates its signature and corrects effective consumption', function () { @@ -285,3 +294,74 @@ test('voided receipt matching blocks new invoices while preserving invoice facts $this->get('/facturas')->assertOk(); $this->actingAs($this->f['receiver'])->get('/recepcoes/'.$this->receipt->id)->assertOk(); }); +test('reversal task links independent approval and exactly one physical correction while preserving originals', function (string $kind) { + $operation = $kind === 'operation' ? reversalStock($this) : null; + $source = $kind === 'operation' ? ['source_kind' => 'operation', 'source_operation_id' => $operation->id] : ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]; + $original = $operation?->fresh()->getAttributes() ?? $this->receiptItem->fresh()->getAttributes(); + $draft = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'TASK-'.Str::uuid(), ...$source])); + $path = '/stock/estornos?record_id='.$draft->id; + supplyGrant($this->f['receiver'], ['inventory_reversal_review']); + supplyGrant($this->f['issuer'], ['warehouse']); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.task', 'reversal_approve')->where('tasks.data.0.href', $path.'#reversal-'.$draft->id)->missing('tasks.data.0.snapshot')); + $before = [SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), DB::table('invoices')->count()]; + $this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canApprove', true)); + expect([SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), DB::table('invoices')->count()])->toBe($before); + $approved = app(InventoryReversals::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft)); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canExecute', false)); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'reversal_execute')); + $command = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Correcção integral confirmada no ensaio descartável.'], 'execute', $approved); + $this->postJson('/stock/estornos', $command)->assertRedirect(); + $this->postJson('/stock/estornos', $command)->assertRedirect(); + expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe($kind === 'operation' ? 10000 : 0) + ->and(StockMovement::query()->count())->toBe($before[1] + 1)->and(DB::table('invoices')->count())->toBe($before[3]) + ->and($operation?->fresh()->getAttributes() ?? $this->receiptItem->fresh()->getAttributes())->toBe($original); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); +})->with(['operation', 'receipt']); + +test('reversal task does not withdraw unavailable receipt stock and still permits an authorised return', function () { + $approved = approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]); + reversalStock($this, '1.000'); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.blocked', true)); + $this->postJson('/stock/estornos', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'O consumo posterior deve impedir uma retirada sem saldo.'], 'execute', $approved))->assertUnprocessable(); + expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(9000); + $this->actingAs($this->f['issuer']); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'reversal_return')->where('tasks.data.0.blocked', true)); + $returned = app(InventoryReversals::class)->save($this->f['issuer'], supplyInput([], 'return', $approved)); + expect($returned->state)->toBe('returned')->and($returned->snapshot['source'])->toBe($approved->snapshot['source']); +}); + +test('reversal task preserves older pending sources beyond the hundred row overview', function () { + $operation = reversalStock($this); + $first = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'OLD-'.Str::uuid(), 'source_kind' => 'operation', 'source_operation_id' => $operation->id])); + for ($index = 1; $index < 105; $index++) { + app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'PENDING-'.Str::uuid(), 'source_kind' => 'operation', 'source_operation_id' => $operation->id])); + } + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25) + ->where('tasks.data.0.id', 'reversal:'.$first->id.':approve')); + $this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); + $this->get('/stock/estornos')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 105)); + $this->get('/stock/estornos?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)); +}); + +test('reversal task preserves record kind company maintenance and assignment expiry boundaries', function () { + $operation = reversalStock($this); + $draft = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'SCOPE-'.Str::uuid(), 'source_kind' => 'operation', 'source_operation_id' => $operation->id])); + $path = '/stock/estornos?record_id='.$draft->id; + $other = receiptFixture(); + supplyGrant($other['receiver'], ['warehouse']); + $this->actingAs($other['receiver'])->getJson($path)->assertUnprocessable(); + $this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['receiver']); + $disposition = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(['kind' => 'write_off', 'reference' => 'OTHER-'.Str::uuid(), + 'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'quantity' => '1.000'])); + $this->getJson('/stock/estornos?record_id='.$disposition->id)->assertUnprocessable(); + DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertForbidden(); + expect(app(InventoryReversals::class)->canAct($this->f['issuer'], $draft, 'approve'))->toBeFalse(); + DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden(); +}); diff --git a/tests/Feature/NativePayablePageTest.php b/tests/Feature/NativePayablePageTest.php new file mode 100644 index 0000000..4308518 --- /dev/null +++ b/tests/Feature/NativePayablePageTest.php @@ -0,0 +1,82 @@ +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]); + $this->f=\paymentFixture();$this->instance='native-page-'.$this->f['tenant']->id;config(['common-payables.enabled'=>true,'common-payables.history_enabled'=>true,'common-payables.paged_read_enabled'=>true,'common-payables.source_instance'=>$this->instance]); + $this->reader=User::factory()->create(['tenant_id'=>$this->f['tenant']->id,'purchasing_role'=>PurchasingRole::Finance]); + foreach([[$this->f['author'],['view','prepare','validate']],[$this->f['validator'],['view','prepare','validate']],[$this->reader,['view']]] as [$u,$actions])foreach($actions as $a)DB::table('pa_access')->insert(['tenant_id'=>$u->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$u->id,'person'=>'native-page-person-'.$u->id,'action'=>$a]); + foreach([['30.00','confirmed'],['10.00','pending'],['5.00','rejected']] as [$amount,$status]){$this->actingAs($this->f['author']);$p=app(ExternalPaymentWorkflow::class)->register($this->f['author'],$this->f['invoice'],[...$this->f['payment_data'],'amount'=>$amount,'external_reference'=>'NATIVE-PAGE-'.$status]);if($status!=='pending'){$this->actingAs($this->f['validator']);app(ExternalPaymentWorkflow::class)->review($this->f['validator'],$p,$status,'Synthetic independent original payment review.');}} + $this->actingAs($this->f['author']);$p=app(SupplierPayables::class)->engine($this->f['author'])->command('prepare_history',$this->f['invoice']->id,'Synthetic original history proposal.',(string)Str::uuid());$this->actingAs($this->f['validator']);app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history',$p['proposal_id'],'Synthetic distinct original history review.',(string)Str::uuid());$this->actingAs($this->reader); + } + private function service():SupplierPayables{return app(SupplierPayables::class);} + private function clones(int $count):void + { + $row=DB::table('invoices')->where('id',$this->f['invoice']->id)->first();$line=(array)DB::table('invoice_items')->where('invoice_id',$row->id)->first();unset($line['id']);$audit=(array)DB::table('audit_logs')->where('tenant_id',$row->tenant_id)->where('action','invoice.validated')->where('after->invoice_id',$row->id)->first();unset($audit['id']); + for($n=1;$n<=$count;$n++){$copy=(array)$row;unset($copy['id']);$copy['number']='PAGE-'.str_pad((string)$n,5,'0',STR_PAD_LEFT);$snapshot=json_decode($copy['snapshot'],true);$snapshot['supplier']['name']=$n%2===0?'Page Alpha':'Page Beta';$copy['snapshot']=json_encode($snapshot,JSON_THROW_ON_ERROR);$id=DB::table('invoices')->insertGetId($copy);DB::table('invoice_items')->insert(array_replace($line,['invoice_id'=>$id]));$after=json_decode($audit['after'],true);$after['invoice_id']=$id;DB::table('audit_logs')->insert(array_replace($audit,['after'=>json_encode($after,JSON_THROW_ON_ERROR)]));} + } + private function positions(array $data):string{$rows=$data['obligations'];usort($rows,fn($a,$b)=>$a['id']<=>$b['id']);return Payables::canonical($rows);} + private function duplicateRegistration():void + { + $p=DB::table('external_payments')->where('tenant_id',$this->f['tenant']->id)->where('invoice_id',$this->f['invoice']->id)->where('status','confirmed')->first();$row=(array)DB::table('audit_logs')->where('tenant_id',$p->tenant_id)->where('action','payment.registered')->where('after->payment_id',$p->id)->first();unset($row['id']);DB::table('audit_logs')->insert($row); + } + public function test_native_page_matches_live_documents_and_existing_financial_positions():void + { + $old=$this->service()->engine($this->reader)->listing([$this->f['invoice']->id]);$new=$this->service()->listing($this->reader);$this->assertSame(Payables::canonical($old['documents']),Payables::canonical($new['documents']));$this->assertSame($this->positions($old),$this->positions($new));$o=$new['obligations'][0];$this->assertSame(6000,$o['principal_minor']);$this->assertSame(3000,$o['balance_minor']);$this->assertSame(1000,$o['pending_payments_minor']);$this->assertSame(500,$o['rejected_payments_minor']);$this->assertNull($o['due_on']);$this->assertFalse($new['permissions']['prepare']);$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',1)->assertJsonPath('obligations.0.balance_minor',3000); + } + public function test_native_invoice_filter_survives_document_and_evidence_pagination():void + { + $this->clones(50);$a=$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',51)->assertJsonCount(25,'documents')->json('documents');$b=$this->getJson('/api/supplier-payables?page=2')->assertOk()->assertJsonCount(25,'documents')->json('documents');$this->assertEmpty(array_intersect(array_column($a,'local_id'),array_column($b,'local_id')));$this->assertGreaterThan($b[0]['local_id'],$a[24]['local_id']); + $id=$this->f['invoice']->id;$this->getJson('/api/supplier-payables?invoice='.$id)->assertOk()->assertJsonPath('documents.0.local_id',$id)->assertJsonPath('obligations.0.balance_minor',3000)->assertJsonPath('listing_query.invoice',$id);$this->getJson('/api/supplier-payables?recognition=recognised')->assertOk()->assertJsonPath('pagination.documents.total',1);$this->getJson('/api/supplier-payables?recognition=unrecognised&per_page=100')->assertOk()->assertJsonPath('pagination.documents.total',50)->assertJsonCount(0,'obligations');$this->getJson('/api/supplier-payables?q=Page%20Alpha')->assertOk()->assertJsonPath('pagination.documents.total',25);$this->getJson('/api/supplier-payables?q=%25')->assertOk()->assertJsonPath('pagination.documents.total',0); + $page=$this->get('/obrigacoes-fornecedores?invoice='.$id)->assertOk()->getContent();$this->assertTrue(str_contains($page,'action="/obrigacoes-fornecedores"'),'Native page action retained');$this->assertTrue(str_contains($page,'name="invoice"'),'Invoice filter retained in search form');$this->assertTrue(str_contains($page,'value="'.$id.'"'),'Original invoice selection retained'); + } + public function test_native_page_batches_sources_and_payment_audits_without_writer_locks():void + { + $this->clones(50);$queries=[];$capture=true;DB::listen(function($q)use(&$queries,&$capture){if($capture)$queries[]=$q->sql;});$this->service()->listing($this->reader);$capture=false;$small=count($queries);$this->assertEmpty(array_filter($queries,fn($q)=>str_contains(strtolower($q),'for update')));$queries=[];$capture=true;$data=$this->service()->listing($this->reader,null,['per_page'=>100]);$capture=false;$this->assertCount(51,$data['documents']);$this->assertSame($small+2,count($queries),'Only two nonempty proposal/event data queries are added when original recognised invoice enters the window');$this->assertCount(2,array_filter($queries,fn($q)=>str_contains($q,'from "audit_logs"')));$this->assertCount(1,array_filter($queries,fn($q)=>str_contains($q,'from "external_payments"'))); + file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/native-page-query.json',json_encode(['documents'=>51,'page_sizes'=>[25,100],'page25_queries'=>$small,'page100_queries'=>count($queries),'extra_queries_reason'=>'Proposal/event data SELECTs skipped by paginator for empty evidence windows.','audit_selects'=>2,'payment_selects'=>1,'no_writer_lock'=>true],JSON_PRETTY_PRINT)); + } + public function test_native_page_refuses_tenant_leaks_revoked_rights_and_stale_role():void + { + DB::table('pa_access')->where('user_id',$this->reader->id)->delete();$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('pa_access')->insert(['tenant_id'=>$this->reader->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$this->reader->id,'person'=>'native-page-person-'.$this->reader->id,'action'=>'view']);DB::table('users')->where('id',$this->reader->id)->update(['purchasing_role'=>'requester']);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('users')->where('id',$this->reader->id)->update(['purchasing_role'=>'finance']); + $tenant=Tenant::query()->create(['name'=>'Foreign native page tenant']);$other=User::factory()->create(['tenant_id'=>$tenant->id,'purchasing_role'=>PurchasingRole::Finance]);DB::table('pa_access')->insert(['tenant_id'=>$tenant->id,'company_id'=>0,'unit_id'=>0,'user_id'=>$other->id,'person'=>'foreign-native-page','action'=>'view']);$this->actingAs($other);$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',0)->assertJsonCount(0,'obligations');$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertNotFound(); + } + public function test_native_audit_changes_require_new_conference_without_using_read_snapshots():void + { + $this->assertSame(3000,$this->service()->listing($this->reader)['obligations'][0]['balance_minor']);$this->duplicateRegistration();$new=$this->service()->listing($this->reader);$old=$this->service()->engine($this->reader)->listing([$this->f['invoice']->id]);$this->assertSame($this->positions($old),$this->positions($new));$this->assertNull($new['obligations'][0]['balance_minor']);$this->assertTrue($new['obligations'][0]['source_conflict']);$this->actingAs($this->f['author']);$r=$this->service()->engine($this->f['author'])->command('prepare_history',$this->f['invoice']->id,'Synthetic new audit after read.',(string)Str::uuid());$this->assertSame('conflict',$r['state']);$this->assertSame(1,DB::table('pa_obligations')->where('tenant_id',$this->f['tenant']->id)->count()); + } + public function test_native_feature_disabled_preserves_legacy_and_invalid_filters_are_refused():void + { + foreach(['page=0','per_page=1000','tenant_id=999','recognition=paid','q%5Ba%5D=x'] as $q)$this->getJson('/api/supplier-payables?'.$q)->assertStatus(422);config(['common-payables.paged_read_enabled'=>false]);$this->getJson('/api/supplier-payables?page=1')->assertStatus(503);$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()->assertJsonPath('obligations.0.balance_minor',3000)->assertJsonMissingPath('pagination'); + } + public function test_native_profiles_expiry_maintenance_and_people_context_do_not_grant_financial_read():void + { + \supplyGrant($this->reader,['finance']);DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['expires_at'=>now()->subMinute()]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['expires_at'=>null,'maintenance'=>true]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['maintenance'=>false]); + $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(['people.internal'])]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_profiles')->where('tenant_id',$tenant)->update(['work_contexts'=>json_encode(['procurement.internal'])]);$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('obligations.0.balance_minor',3000); + } + public function test_native_read_finishes_before_writer_release_and_sees_committed_audits_only():void + { + $dir=base_path('../private/native-page-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';file_put_contents($input,json_encode(['actor'=>$this->reader->id,'instance'=>$this->instance],JSON_THROW_ON_ERROR));$process=null;DB::beginTransaction(); + try{DB::table('tenants')->where('id',$this->f['tenant']->id)->lockForUpdate()->firstOrFail();$this->duplicateRegistration();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/native-payable-page-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]);$deadline=microtime(true)+8;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running'],'Read must not wait for writer tenant lock');$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(200,$r['status']);$this->assertSame([3000],$r['balances']);$this->assertSame(1,DB::transactionLevel());file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/native-page-concurrency.json',json_encode(['reader_completed_before_writer_release'=>true,'uncommitted_audit_not_observed'=>true,'balance_minor'=>3000,'reader_time_ms'=>$r['time_ms']],JSON_PRETTY_PRINT));} + finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();} + } + public function test_native_missing_package_capability_is_503_without_legacy_fallback():void + { + $dir=base_path('../private/native-page-package-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';file_put_contents($input,json_encode(['actor'=>$this->reader->id,'instance'=>$this->instance],JSON_THROW_ON_ERROR));$pipes=[];$environment=getenv();$environment['COMMON_PAYABLES_PATH']='C:/laragon/www/compras/packages/payables';$p=proc_open([PHP_BINARY,base_path('tests/Support/native-payable-page-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path(),$environment);$this->assertIsResource($p);fclose($pipes[0]);$this->assertSame(0,proc_close($p));$r=json_decode(file_get_contents($input.'.result'),true);$this->assertSame(503,$r['status']); + } +} diff --git a/tests/Feature/ProcurementReportsTest.php b/tests/Feature/ProcurementReportsTest.php index 6e674c1..ced769e 100644 --- a/tests/Feature/ProcurementReportsTest.php +++ b/tests/Feature/ProcurementReportsTest.php @@ -3,21 +3,43 @@ use App\Enums\PurchasingRole; use App\Models\AuditLog; use App\Models\Department; +use App\Models\Quotation; use App\Models\Requisition; use App\Models\User; use App\Services\ExternalPaymentWorkflow; use Carbon\CarbonImmutable; +use Illuminate\Routing\Exceptions\StreamedResponseException; use Illuminate\Support\Facades\DB; use Inertia\Testing\AssertableInertia as Assert; +use Symfony\Component\HttpKernel\Exception\HttpException; require_once __DIR__.'/../Support/ReceiptFixture.php'; require_once __DIR__.'/../Support/PaymentFixture.php'; beforeEach(function () { $this->f = receiptFixture(); + if (DB::getDriverName() === 'pgsql') { + // Reports deliberately require their own read snapshot, outside the test wrapper. + // RefreshDatabase detects the completed wrapper and remigrates the disposable DB. + if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName()) || DB::transactionLevel() !== 1) { + throw new LogicException('Relatórios PostgreSQL apenas na base descartável e transacção de teste esperadas.'); + } + DB::commit(); + } $this->actingAs($this->f['issuer']); }); +afterEach(function () { + if (DB::getDriverName() === 'pgsql') { + if (! app()->environment('testing') || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName()) || DB::transactionLevel() !== 0) { + throw new LogicException('Limpeza apenas do schema da base descartável fora de transacções.'); + } + // Committed fixtures require schema cleanup, including functions migrate:fresh leaves behind. + DB::statement('DROP SCHEMA public CASCADE'); + DB::statement('CREATE SCHEMA public'); + } +}); + function reportCopy(Requisition $original, string $number, array $attributes = []): Requisition { $copy = $original->replicate(); @@ -26,6 +48,110 @@ function reportCopy(Requisition $original, string $number, array $attributes = [ return $copy; } +test('needed attention report filters all pages and CSV by native status and needed date without writes', function () { + $this->freezeTime(); + foreach (range(1, 27) as $index) { + reportCopy($this->f['r'], 'ATTENTION-'.$index, ['status' => $index === 1 ? 'draft' : 'pending', 'needed_by' => today()->subDay()->toDateString()]); + } + foreach (['approved', 'rejected', 'cancelled'] as $status) { + reportCopy($this->f['r'], 'CLOSED-'.$status, ['status' => $status, 'needed_by' => today()->subDay()->toDateString()]); + } + reportCopy($this->f['r'], 'NEEDED-TODAY', ['status' => 'pending', 'needed_by' => today()->toDateString()]); + reportCopy($this->f['r'], 'NEEDED-FUTURE', ['status' => 'draft', 'needed_by' => today()->addDay()->toDateString()]); + $before = []; + foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) { + $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); + } + $this->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 27)->has('records.data', 25) + ->where('insights.unapproved_past_needed_date', 27)->where('counts.pending', 26)->where('counts.draft', 1) + ->where('records.data.0.needed_by', today()->subDay()->toDateString()) + ->where('records.next_page_url', fn ($url) => str_contains($url, 'attention=unapproved_needed'))); + $this->get('/relatorios?attention=unapproved_needed&page=2')->assertInertia(fn (Assert $p) => $p->has('records.data', 2)); + $csv = $this->get('/relatorios/compras.csv?attention=unapproved_needed&page=2')->assertOk()->streamedContent(); + expect($csv)->toContain('ATTENTION-1')->toContain('ATTENTION-27')->toContain('Data de necessidade')->not->toContain('CLOSED-')->not->toContain('NEEDED-TODAY')->not->toContain('NEEDED-FUTURE'); + $this->get('/relatorios?attention=unapproved_needed&status=approved')->assertInertia(fn (Assert $p) => $p->where('records.total', 0)); + $this->getJson('/relatorios?attention=unknown')->assertUnprocessable(); + $this->getJson('/relatorios/compras.csv?attention=unknown')->assertUnprocessable(); + foreach ($before as $table => $rows) { + expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); + } + $this->travel(1)->days(); + $this->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 28)); +}); + +test('needed attention report rechecks stream access and preserves department boundaries', function () { + $past = reportCopy($this->f['r'], 'OWN-ATTENTION', ['status' => 'pending', 'needed_by' => today()->subDay()->toDateString()]); + $head = User::factory()->create(['tenant_id' => $past->tenant_id, 'department_id' => $past->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + $otherDepartment = Department::query()->create(['tenant_id' => $past->tenant_id, 'code' => 'ATT-OTHER', 'name' => 'Outro departamento']); + reportCopy($past, 'PRIVATE-OTHER-DEPARTMENT', ['department_id' => $otherDepartment->id]); + $this->actingAs($head)->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 1)); + expect($this->get('/relatorios/compras.csv?attention=unapproved_needed')->assertOk()->streamedContent())->not->toContain('PRIVATE-OTHER-DEPARTMENT'); + $response = $this->get('/relatorios/compras.csv?attention=unapproved_needed')->assertOk(); + $head->forceFill(['purchasing_role' => PurchasingRole::Requester])->save(); + $bufferLevel = ob_get_level(); + try { + $response->streamedContent(); + test()->fail('Revoked report access must fail before streaming'); + } catch (StreamedResponseException $error) { + expect($error->getInnerException())->toBeInstanceOf(HttpException::class); + expect($error->getInnerException()->getStatusCode())->toBe(403); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } +}); + +test('approved attention reports reuse registered quote and visible order facts across every page and CSV', function () { + foreach (range(1, 27) as $index) { + reportCopy($this->f['r'], 'EMPTY-APPROVED-'.$index); + } + $withQuote = reportCopy($this->f['r'], 'REGISTERED-NO-ORDER'); + $quote = Quotation::query()->where('requisition_id', $this->f['r']->id)->firstOrFail()->replicate(); + $quote->requisition_id = $withQuote->id; + $quote->reference = 'REGISTERED-EXPIRED'; + $quote->valid_until = today()->subDay(); + $quote->save(); + reportCopy($this->f['r'], 'PENDING-NO-QUOTE', ['status' => 'pending']); + $before = []; + foreach (['requisitions', 'quotations', 'purchase_orders', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) { + $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); + } + $this->get('/relatorios')->assertInertia(fn (Assert $p) => $p->where('insights.approved.without_registered_quotes', 27)->where('insights.approved.without_visible_order', 28)); + foreach (['approved_without_quotes' => 27, 'approved_without_order' => 28] as $attention => $total) { + $base = '/relatorios?attention='.$attention; + $this->get($base)->assertInertia(fn (Assert $p) => $p->where('records.total', $total)->has('records.data', 25)->where('counts.approved', $total) + ->where('records.next_page_url', fn ($url) => str_contains($url, 'attention='.$attention))); + $this->get($base.'&page=2')->assertInertia(fn (Assert $p) => $p->has('records.data', $total - 25)); + $csv = $this->get('/relatorios/compras.csv?attention='.$attention.'&page=2')->assertOk()->streamedContent(); + expect($csv)->toContain('EMPTY-APPROVED-1')->toContain('EMPTY-APPROVED-27')->not->toContain('PENDING-NO-QUOTE'); + if ($attention === 'approved_without_quotes') { + expect($csv)->not->toContain('REGISTERED-NO-ORDER'); + } else { + expect($csv)->toContain('REGISTERED-NO-ORDER'); + } + $this->get($base.'&status=pending')->assertInertia(fn (Assert $p) => $p->where('records.total', 0)); + } + foreach ($before as $table => $rows) { + expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); + } +}); + +test('approved attention reports keep department and company scopes while finding registered facts', function () { + $own = reportCopy($this->f['r'], 'OWN-EMPTY-APPROVED'); + $department = Department::query()->create(['tenant_id' => $own->tenant_id, 'name' => 'Departamento reservado']); + reportCopy($own, 'PRIVATE-OTHER-DEPT', ['department_id' => $department->id]); + $head = User::factory()->create(['tenant_id' => $own->tenant_id, 'department_id' => $own->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + $foreign = receiptFixture(); + reportCopy($foreign['r'], 'PRIVATE-OTHER-COMPANY'); + $this->actingAs($head); + foreach (['approved_without_quotes', 'approved_without_order'] as $attention) { + $this->get('/relatorios?attention='.$attention)->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $own->id)); + $csv = $this->get('/relatorios/compras.csv?attention='.$attention)->assertOk()->streamedContent(); + expect($csv)->toContain('OWN-EMPTY-APPROVED')->not->toContain('PRIVATE-OTHER-DEPT')->not->toContain('PRIVATE-OTHER-COMPANY'); + } +}); + test('report entry connects visible requests to the existing process and report without business writes', function () { $audits = AuditLog::query()->count(); $this->get('/relatorios')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page diff --git a/tests/Feature/ReceiptDocumentBlockTest.php b/tests/Feature/ReceiptDocumentBlockTest.php new file mode 100644 index 0000000..9ed8f9e --- /dev/null +++ b/tests/Feature/ReceiptDocumentBlockTest.php @@ -0,0 +1,127 @@ +f = receiptFixture(); + $this->actingAs($this->f['receiver']); + $this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f)); + $this->directory = storage_path('framework/testing/receipt-block-'.Str::uuid()); + Storage::set('receipt_documents', Storage::build(['driver' => 'local', 'root' => $this->directory, 'throw' => true])); + $this->image = UploadedFile::fake()->createWithContent('guia.png', base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6E4sAAAAASUVORK5CYII=')); + $this->key = (string) Str::uuid(); + $this->url = '/recepcoes/'.$this->receipt->id; +}); + +afterEach(function () { + File::deleteDirectory($this->directory); +}); + +function receiptDocumentInput(object $test, ?string $key = null, ?UploadedFile $file = null): array +{ + return ['operation_key' => $key ?? $test->key, 'document_type' => 'delivery_note', 'file' => $file ?? $test->image]; +} + +test('receipt document block uploads reuses and downloads evidence without repeating receipt stock or approvals', function () { + $receiptBefore = $this->receipt->getAttributes(); + $before = []; + foreach (['goods_receipts', 'goods_receipt_items', 'stock_movements', 'approval_steps', 'budget_entries', 'purchase_orders'] as $table) { + $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); + } + $this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect($this->url.'#receipt-documents'); + $document = ReceiptDocument::query()->sole(); + $this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect(); + $this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertRedirect(); + expect(ReceiptDocument::query()->count())->toBe(1)->and(Storage::disk('receipt_documents')->allFiles())->toHaveCount(1); + expect(DB::table('audit_logs')->where('action', 'receipt.document_added')->count())->toBe(1); + expect(DB::table('audit_logs')->where('action', 'receipt.document_reused')->count())->toBe(1); + $this->get($this->url)->assertInertia(fn (Assert $p) => $p->where('documentsReady', true)->where('canAddDocuments', true) + ->has('documents', 1)->missing('documents.0.path')->where('documents.0.id', $document->id)); + $response = $this->get($this->url.'/documentos/'.$document->id)->assertOk()->assertHeader('Content-Type', 'application/octet-stream') + ->assertHeader('X-Content-Type-Options', 'nosniff')->assertHeader('Cache-Control', 'no-store, private'); + expect(hash('sha256', $response->streamedContent()))->toBe($document->sha256); + foreach ($before as $table => $rows) { + expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); + } + expect($this->receipt->fresh()->getAttributes())->toBe($receiptBefore); +}); + +test('receipt document block preserves role independence tenant and receipt scopes', function () { + $this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect(); + $document = ReceiptDocument::query()->sole(); + $this->actingAs($this->f['issuer'])->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden(); + $this->get($this->url.'/documentos/'.$document->id)->assertOk(); + supplyGrant($this->f['receiver'], ['warehouse']); + supplyGrant($this->f['issuer'], ['warehouse']); + $this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden(); + $foreign = receiptFixture(); + $this->actingAs($foreign['receiver'])->get($this->url.'/documentos/'.$document->id)->assertNotFound(); + $this->post($this->url.'/documentos', receiptDocumentInput($this))->assertNotFound(); + $this->actingAs($this->f['receiver']); + $another = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '0.100')); + $this->get('/recepcoes/'.$another->id.'/documentos/'.$document->id)->assertNotFound(); + supplyGrant($this->f['receiver'], ['warehouse']); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true]); + $this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden(); + $this->get($this->url.'/documentos/'.$document->id)->assertForbidden(); +}); + +test('receipt document block rejects changed replay and invalid files and detects tampering', function () { + $this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect(); + $document = ReceiptDocument::query()->sole(); + $this->withHeader('Accept', 'application/json')->post($this->url.'/documentos', receiptDocumentInput($this, null, UploadedFile::fake()->createWithContent('guia.png', file_get_contents($this->image->getPathname()).'changed')))->assertUnprocessable(); + $this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid(), UploadedFile::fake()->create('bad.html', 1, 'text/html')))->assertUnprocessable(); + $this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid(), UploadedFile::fake()->create('large.pdf', 2049, 'application/pdf')))->assertUnprocessable(); + expect(ReceiptDocument::query()->count())->toBe(1)->and(Storage::disk('receipt_documents')->allFiles())->toHaveCount(1); + foreach (['update', 'delete'] as $operation) { + try { + DB::transaction(function () use ($document, $operation) { + $query = DB::table('receipt_documents')->where('id', $document->id); + $operation === 'update' ? $query->update(['original_name' => 'changed.png']) : $query->delete(); + }); + test()->fail('Document metadata must remain append-only'); + } catch (QueryException) { + expect($document->fresh()->original_name)->toBe('guia.png'); + } + } + Storage::disk('receipt_documents')->put($document->path, 'TAMPERED'); + $this->get($this->url.'/documentos/'.$document->id)->assertStatus(409); + Storage::disk('receipt_documents')->delete($document->path); + $this->get($this->url.'/documentos/'.$document->id)->assertNotFound(); +}); + +test('receipt document block rolls back metadata and private file if audit fails then permits safe retry', function () { + $this->partialMock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected document audit failure')); + try { + app(ReceiptDocuments::class)->add($this->f['receiver'], $this->receipt, receiptDocumentInput($this), $this->image); + test()->fail('Expected rollback'); + } catch (RuntimeException $error) { + expect($error->getMessage())->toBe('Injected document audit failure'); + } + expect(ReceiptDocument::query()->count())->toBe(0)->and(Storage::disk('receipt_documents')->allFiles())->toBe([]); + app()->forgetInstance(RequisitionWorkflow::class); + $document = app(ReceiptDocuments::class)->add($this->f['receiver'], $this->receipt, receiptDocumentInput($this), $this->image); + expect($document->id)->toBeGreaterThan(0)->and(ReceiptDocument::query()->count())->toBe(1); +}); + +test('receipt document block leaves the established receipt page usable before installation', function () { + Schema::drop('receipt_documents'); + $this->get($this->url)->assertOk()->assertInertia(fn (Assert $p) => $p->where('documentsReady', false)->where('canAddDocuments', false)->has('documents', 0)); + $this->post($this->url.'/documentos', receiptDocumentInput($this))->assertStatus(503); + expect(GoodsReceipt::query()->whereKey($this->receipt->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/ReplenishmentHealthTest.php b/tests/Feature/ReplenishmentHealthTest.php new file mode 100644 index 0000000..0991ab6 --- /dev/null +++ b/tests/Feature/ReplenishmentHealthTest.php @@ -0,0 +1,111 @@ +freezeTime(); + $this->f = receiptFixture(); + $this->originalStorage = app()->storagePath(); + $this->temporaryStorage = $this->originalStorage.'/framework/testing/health-'.Str::uuid(); + File::ensureDirectoryExists($this->temporaryStorage.'/framework'); + app()->useStoragePath($this->temporaryStorage); + auth()->guard()->logout(); +}); + +afterEach(function () { + app()->useStoragePath($this->originalStorage); + File::deleteDirectory($this->temporaryStorage); +}); + +test('replenishment health is read only and emits success without creating work purchases or telemetry', function () { + app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0); + $path = storage_path('framework/replenishment-command-status.json'); + $beforeFile = File::get($path); + $before = []; + foreach (['requisitions', 'stock_movements', 'audit_logs', 'replenishment_work', 'supply_records'] as $table) { + $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); + } + $result = app(ReplenishmentQueue::class)->health(); + expect($result['ok'])->toBeTrue()->and($result['reasons'])->toBe([])->and($result['work'])->toBe(['total' => 0, 'pending' => 0, 'failed' => 0]); + $this->artisan('sgc:replenishment-health', ['--json' => true])->expectsOutput(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))->assertExitCode(0); + expect(auth()->check())->toBeFalse()->and(File::get($path))->toBe($beforeFile); + foreach ($before as $table => $rows) { + expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); + } +}); + +test('replenishment health reports missing stale damaged or unsuccessful observation without certifying execution', function () { + expect(app(ReplenishmentQueue::class)->health()['reasons'])->toContain('not_recent', 'last_run_not_successful'); + File::put(storage_path('framework/replenishment-command-status.json'), '{broken'); + expect(app(ReplenishmentQueue::class)->health()['ok'])->toBeFalse(); + File::put(storage_path('framework/replenishment-command-status.json'), json_encode(['result' => 'passed', 'finished_at' => now()->subHour()->toIso8601String(), 'installation' => app(ReplenishmentHeartbeat::class)->fingerprint()], JSON_THROW_ON_ERROR)); + expect(app(ReplenishmentQueue::class)->health()['reasons'])->toBe(['not_recent']); + app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 1); + expect(app(ReplenishmentQueue::class)->health()['reasons'])->toBe(['last_run_not_successful']); + $this->artisan('sgc:replenishment-health')->expectsOutput('Reposição: verificação técnica requer atenção.')->expectsOutput('Última passagem sem sucesso confirmado.')->assertExitCode(1); +}); + +test('replenishment health aggregates pending failures while the workdesk stays tenant scoped and unchanged', function () { + $queue = app(ReplenishmentQueue::class); + $this->actingAs($this->f['receiver']); + $first = $queue->enqueue($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id); + $foreign = receiptFixture(); + $this->actingAs($foreign['receiver']); + $second = $queue->enqueue($foreign['receiver'], $foreign['stock']->id, $foreign['warehouse']->id); + DB::table('replenishment_work')->where('id', $second)->update(['failures' => 1, 'last_error' => 'PRIVATE-WORK-DETAIL']); + $before = DB::table('replenishment_work')->orderBy('id')->get()->toArray(); + app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0); + auth()->guard()->logout(); + $result = $queue->health(); + expect($result['ok'])->toBeFalse()->and($result['reasons'])->toBe(['pending_signals', 'failed_work']) + ->and($result['work'])->toBe(['total' => 2, 'pending' => 2, 'failed' => 1]); + expect(json_encode($result))->not->toContain('PRIVATE-WORK-DETAIL')->not->toContain('tenant_id')->not->toContain('responsible_id'); + expect(DB::table('replenishment_work')->orderBy('id')->get()->toArray())->toEqual($before); + $this->actingAs($this->f['receiver']); + expect((int) $queue->status($this->f['receiver'])['work']->total)->toBe(1); + expect(DB::table('replenishment_work')->where('id', $first)->value('processed_generation'))->toBe(0); + $unassigned = User::factory()->create(['tenant_id' => null]); + $this->actingAs($unassigned); + expect((int) $queue->status($unassigned)['work']->total)->toBe(0); +}); + +test('replenishment health rejects interactive identity and unavailable composition without processing work', function () { + $this->actingAs($this->f['receiver']); + try { + app(ReplenishmentQueue::class)->health(); + test()->fail('Interactive identity must not be used for technical health'); + } catch (HttpException $error) { + expect($error->getStatusCode())->toBe(403); + } + auth()->guard()->logout(); + config(['work_context.composition' => 'sgf']); + $result = app(ReplenishmentQueue::class)->health(); + expect($result['ok'])->toBeFalse()->and($result['recovery_ready'])->toBeFalse()->and($result['reasons'])->toContain('not_installed')->and($result['work'])->toBeNull(); +}); + +test('replenishment health command returns generic unavailability without exposing an exception', function () { + $this->mock(ReplenishmentQueue::class)->shouldReceive('health')->once()->andThrow(new RuntimeException('PRIVATE-CONNECTION-DETAIL')); + $this->artisan('sgc:replenishment-health', ['--json' => true])->expectsOutput(json_encode(['checked_at' => now()->toIso8601String(), 'ok' => false, 'reasons' => ['unavailable']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))->assertExitCode(1); +}); + +test('replenishment health requires a bound observation and rejects another installation without leaking its identity', function () { + $path = storage_path('framework/replenishment-command-status.json'); + File::put($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR)); + expect(app(ReplenishmentQueue::class)->health()['reasons'])->toBe(['unbound_observation']); + File::put($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String(), 'installation' => str_repeat('a', 64)], JSON_THROW_ON_ERROR)); + expect(app(ReplenishmentQueue::class)->health()['ok'])->toBeFalse(); + $this->actingAs($this->f['receiver']); + expect(app(ReplenishmentQueue::class)->status($this->f['receiver'])['schedulerRecent'])->toBeFalse(); + auth()->guard()->logout(); + app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0); + expect(app(ReplenishmentQueue::class)->health()['ok'])->toBeTrue(); + expect(json_encode(app(ReplenishmentQueue::class)->health()))->not->toContain(app(ReplenishmentHeartbeat::class)->fingerprint()); +}); diff --git a/tests/Feature/ReplenishmentHeartbeatTest.php b/tests/Feature/ReplenishmentHeartbeatTest.php new file mode 100644 index 0000000..e003ca7 --- /dev/null +++ b/tests/Feature/ReplenishmentHeartbeatTest.php @@ -0,0 +1,106 @@ +freezeTime(); + $this->originalStorage = app()->storagePath(); + $this->heartbeatStorage = $this->originalStorage.'/framework/testing/heartbeat-'.Str::uuid(); + File::ensureDirectoryExists($this->heartbeatStorage.'/framework'); + app()->useStoragePath($this->heartbeatStorage); +}); + +afterEach(function () { + app()->useStoragePath($this->originalStorage); + File::deleteDirectory($this->heartbeatStorage); +}); + +test('native replenishment command records success or pending recovery without identities', function (int $failed, int $code, string $result) { + $queue = $this->mock(ReplenishmentQueue::class); + $queue->shouldReceive('ready')->once()->andReturnTrue(); + $queue->shouldReceive('discover')->once(); + $queue->shouldReceive('drain')->with(12)->once()->andReturn(['processed' => 2, 'failed' => $failed]); + $this->artisan('sgc:replenishment-tick', ['--limit' => 12])->assertExitCode($code); + $data = json_decode(File::get(storage_path('framework/replenishment-command-status.json')), true, 32, JSON_THROW_ON_ERROR); + expect($data)->toHaveCount(6)->and($data['result'])->toBe($result)->and($data['exit_code'])->toBe($code) + ->and($data['finished_at'])->toBe(now()->toIso8601String()); +})->with([[0, 0, 'passed'], [1, 1, 'retry_pending']]); + +test('interrupted discovery replaces a previous success with recovery pending and preserves the original error', function () { + File::put(storage_path('framework/replenishment-command-status.json'), '{"result":"passed"}'); + $queue = $this->mock(ReplenishmentQueue::class); + $queue->shouldReceive('ready')->once()->andReturnTrue(); + $queue->shouldReceive('discover')->once()->andThrow(new RuntimeException('Synthetic discovery interruption')); + $queue->shouldNotReceive('drain'); + try { + $this->artisan('sgc:replenishment-tick')->run(); + test()->fail('Expected discovery failure'); + } catch (RuntimeException $error) { + expect($error->getMessage())->toBe('Synthetic discovery interruption'); + } + $data = json_decode(File::get(storage_path('framework/replenishment-command-status.json')), true, 32, JSON_THROW_ON_ERROR); + expect($data['result'])->toBe('retry_pending')->and($data['exit_code'])->toBe(1); + expect(File::get(storage_path('framework/replenishment-command-status.json')))->not->toContain('Synthetic discovery interruption'); +}); + +test('invalid limits do not publish a successful scheduler observation', function () { + $queue = $this->mock(ReplenishmentQueue::class); + $queue->shouldReceive('ready')->once()->andReturnTrue(); + $queue->shouldNotReceive('discover'); + $queue->shouldNotReceive('drain'); + $this->artisan('sgc:replenishment-tick', ['--limit' => 501])->assertExitCode(2); + expect(File::exists(storage_path('framework/replenishment-command-status.json')))->toBeFalse(); +}); + +test('overlapping native command neither processes work nor replaces the last completed observation', function () { + $held = new ReplenishmentProcessLock; + expect($held->acquire())->toBeTrue(); + $path = storage_path('framework/replenishment-command-status.json'); + File::put($path, '{"result":"retry_pending","finished_at":"previous"}'); + $before = File::get($path); + $queue = $this->mock(ReplenishmentQueue::class); + $queue->shouldReceive('ready')->once()->andReturnTrue(); + $queue->shouldNotReceive('discover'); + $queue->shouldNotReceive('drain'); + try { + $this->artisan('sgc:replenishment-tick')->expectsOutput('Verificação periódica já em execução; aguarde a próxima passagem.')->assertExitCode(1); + expect(File::get($path))->toBe($before); + } finally { + $held->release(); + } + $next = new ReplenishmentProcessLock; + expect($next->acquire())->toBeTrue(); + $next->release(); +}); + +test('exception and successful runs both release native process exclusion', function () { + $queue = $this->mock(ReplenishmentQueue::class); + $queue->shouldReceive('ready')->twice()->andReturnTrue(); + $queue->shouldReceive('discover')->once()->andThrow(new RuntimeException('Synthetic interruption')); + try { + $this->artisan('sgc:replenishment-tick')->run(); + test()->fail('Expected interruption'); + } catch (RuntimeException $error) { + expect($error->getMessage())->toBe('Synthetic interruption'); + } + $queue->shouldReceive('discover')->once(); + $queue->shouldReceive('drain')->once()->andReturn(['processed' => 0, 'failed' => 0]); + $this->artisan('sgc:replenishment-tick')->assertExitCode(0); + $next = new ReplenishmentProcessLock; + expect($next->acquire())->toBeTrue(); + $next->release(); +}); + +test('destroyed worker lock can be acquired without deleting its control file', function () { + $first = new ReplenishmentProcessLock; + expect($first->acquire())->toBeTrue(); + $contender = new ReplenishmentProcessLock; + expect($contender->acquire())->toBeFalse(); + unset($first); + expect(File::exists(storage_path('framework/replenishment-command.lock')))->toBeTrue(); + expect($contender->acquire())->toBeTrue(); + $contender->release(); +}); diff --git a/tests/Feature/ReplenishmentProcessRecoveryTest.php b/tests/Feature/ReplenishmentProcessRecoveryTest.php new file mode 100644 index 0000000..5c38694 --- /dev/null +++ b/tests/Feature/ReplenishmentProcessRecoveryTest.php @@ -0,0 +1,30 @@ +storagePath(); + $temporary = $originalStorage.'/framework/testing/process-recovery-'.Str::uuid(); + File::ensureDirectoryExists($temporary.'/framework'); + app()->useStoragePath($temporary); + $path = storage_path('framework/replenishment-command.lock'); + $worker = new Process([PHP_BINARY, '-r', '$h = fopen($argv[1], "c"); if (!$h || !flock($h, LOCK_EX | LOCK_NB)) { exit(2); } echo "LOCKED"; fflush(STDOUT); sleep(30);', $path], timeout: 10); + $contender = new ReplenishmentProcessLock; + try { + $worker->start(); + expect($worker->waitUntil(fn ($type, $output) => str_contains($output, 'LOCKED')))->toBeTrue(); + expect($contender->acquire())->toBeFalse(); + $worker->stop(0); + expect($worker->isRunning())->toBeFalse(); + expect(File::exists($path))->toBeTrue(); + expect($contender->acquire())->toBeTrue(); + } finally { + $worker->stop(0); + $contender->release(); + app()->useStoragePath($originalStorage); + File::deleteDirectory($temporary); + } +}); diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index c24f0bb..e0afded 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -6,16 +6,14 @@ use Inertia\Testing\AssertableInertia as Assert; use Laravel\Fortify\Features; test('security page is displayed', function () { - $this->skipUnlessFortifyHas(Features::twoFactorAuthentication()); - - Features::twoFactorAuthentication([ + config(['fortify.features' => [Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, - ]); + ])]]); $user = User::factory()->create(); - $this->actingAs($user) + $this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp]) ->get(route('security.edit')) ->assertInertia(fn (Assert $page) => $page ->component('settings/security') diff --git a/tests/Feature/Settings/TwoFactorManagementTest.php b/tests/Feature/Settings/TwoFactorManagementTest.php new file mode 100644 index 0000000..d057589 --- /dev/null +++ b/tests/Feature/Settings/TwoFactorManagementTest.php @@ -0,0 +1,75 @@ +create(); + $this->actingAs($user)->get('/settings/security')->assertRedirect('/user/confirm-password'); + $this->postJson('/user/two-factor-authentication')->assertStatus(423); + expect($user->fresh()->two_factor_secret)->toBeNull(); + $this->withSession(['auth.password_confirmed_at' => now()->timestamp])->get('/settings/security')->assertOk() + ->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p + ->where('canManageTwoFactor', true)->where('twoFactorEnabled', false)->where('twoFactorPending', false)->missing('secretKey')->missing('recoveryCodes')); + expect($user->fresh()->two_factor_secret)->toBeNull(); +}); + +test('mfa management completes native enrollment renewal and disable only for the current account', function () { + foundationMfaRoutes(); + $user = User::factory()->create(); + $other = User::factory()->create(); + $this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp]); + $this->postJson('/user/two-factor-authentication', ['user_id' => $other->id])->assertSuccessful(); + $secret = $this->getJson('/user/two-factor-secret-key')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->json('secretKey'); + expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); + $before = $user->fresh()->getAttributes(); + $this->get('/settings/security')->assertInertia(fn (Assert $p) => $p->where('twoFactorPending', true)->where('twoFactorEnabled', false)->missing('secretKey')->missing('recoveryCodes')); + expect($user->fresh()->getAttributes())->toBe($before); + $code = (new Google2FA)->getCurrentOtp($secret); + $this->postJson('/user/confirmed-two-factor-authentication', ['code' => $code])->assertSuccessful(); + expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue(); + $oldCodes = $this->getJson('/user/two-factor-recovery-codes')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->json(); + $this->postJson('/user/two-factor-recovery-codes')->assertSuccessful(); + $newCodes = $this->getJson('/user/two-factor-recovery-codes')->assertOk()->json(); + expect($oldCodes)->toHaveCount(8)->and($newCodes)->toHaveCount(8)->and(array_intersect($oldCodes, $newCodes))->toBe([]); + $this->withSession(['auth.password_confirmed_at' => 0])->deleteJson('/user/two-factor-authentication')->assertStatus(423); + expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue(); + $this->withSession(['auth.password_confirmed_at' => now()->timestamp])->deleteJson('/user/two-factor-authentication')->assertSuccessful(); + expect($user->fresh()->two_factor_secret)->toBeNull()->and($user->fresh()->two_factor_recovery_codes)->toBeNull()->and($user->fresh()->two_factor_confirmed_at)->toBeNull(); + expect($other->fresh()->two_factor_secret)->toBeNull(); +}); + +test('mfa management invalid confirmation does not flash authentication codes or activate the account', function () { + foundationMfaRoutes(); + $user = User::factory()->create(); + $this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp]); + $this->postJson('/user/two-factor-authentication')->assertSuccessful(); + $this->from('/settings/security')->post('/user/confirmed-two-factor-authentication', ['code' => 'invalid'])->assertSessionHasErrorsIn('confirmTwoFactorAuthentication', ['code']) + ->assertSessionMissing('_old_input.code'); + expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); +}); + +test('mfa management rejects missing csrf and accepts the encrypted xsrf cookie header used by the interface', function () { + foundationMfaRoutes(); + $this->instance(PreventRequestForgery::class, + new class(app(), app('encrypter')) extends PreventRequestForgery + { + protected function runningUnitTests(): bool + { + return false; + } + }); + $user = User::factory()->create(); + $response = $this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp])->get('/settings/security')->assertOk(); + $cookie = $response->getCookie('XSRF-TOKEN', false); + expect($cookie)->not->toBeNull(); + $this->postJson('/user/two-factor-authentication')->assertStatus(419); + expect($user->fresh()->two_factor_secret)->toBeNull(); + $this->withHeader('X-XSRF-TOKEN', $cookie->getValue())->postJson('/user/two-factor-authentication')->assertSuccessful(); + expect($user->fresh()->two_factor_secret)->not->toBeNull(); +}); diff --git a/tests/Feature/SupplierEvaluationTest.php b/tests/Feature/SupplierEvaluationTest.php index e1d0735..b9a9af8 100644 --- a/tests/Feature/SupplierEvaluationTest.php +++ b/tests/Feature/SupplierEvaluationTest.php @@ -9,12 +9,15 @@ use App\Models\SupplierEvaluation; use App\Models\User; use App\Services\RequisitionWorkflow; use App\Services\SupplierEvaluationWorkflow; +use App\Services\WorkflowTaskInbox; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia as Assert; +use Symfony\Component\HttpKernel\Exception\HttpException; require_once __DIR__.'/../Support/ReceiptFixture.php'; +require_once __DIR__.'/../Support/SupplyFixture.php'; beforeEach(function () { $this->f = receiptFixture(); @@ -163,3 +166,96 @@ test('a newly declared process conflict prevents review of the stored evaluation $this->actingAs($this->reviewer)->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 2, 'reason' => 'Tentativa após declaração de conflito'])->assertForbidden(); expect($e->refresh()->status)->toBe('review')->and($e->version)->toBe(2); }); + +function qualityEvaluationTasks(object $test, User $actor): array +{ + $test->actingAs($actor); + + return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), + fn ($row) => str_starts_with($row['id'], 'supplier-evaluation:'))); +} + +test('quality task follows preparation independent review return and approval', function () { + $e = evaluationDraft($this); + expect(qualityEvaluationTasks($this, $this->f['issuer']))->toHaveCount(1) + ->and(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); + $this->actingAs($this->f['issuer'])->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 1])->assertRedirect(); + expect(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([]); + $tasks = qualityEvaluationTasks($this, $this->reviewer); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('supplier_evaluation_review')->and($tasks[0]['due_on'])->toBeNull(); + $this->get($tasks[0]['href'])->assertInertia(fn (Assert $p) => $p->where('canReview', true)); + $this->post('/fornecedores/avaliacoes/'.$e->id.'/return', ['version' => 2, 'reason' => 'Conferir novamente o âmbito e as evidências.'])->assertRedirect(); + expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and(qualityEvaluationTasks($this, $this->f['issuer']))->toHaveCount(1); + $this->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 3])->assertRedirect(); + $this->actingAs($this->reviewer)->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 4, 'reason' => 'Âmbito e evidências conferidos independentemente.'])->assertRedirect(); + expect(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([])->and(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); + $before = $e->refresh()->getAttributes(); + qualityEvaluationTasks($this, $this->reviewer); + expect($e->fresh()->getAttributes())->toBe($before); +}); + +test('quality task excludes all contributors after shared editing and does not publish conclusions', function () { + $e = evaluationDraft($this); + $this->actingAs($this->reviewer)->put('/fornecedores/avaliacoes/'.$e->id, ['version' => 1, 'payload' => [...$this->data['payload'], 'scope' => 'Âmbito corrigido por outro preparador.']])->assertRedirect(); + expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(1); + $this->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 2])->assertRedirect(); + expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([]); + $independent = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); + $tasks = qualityEvaluationTasks($this, $independent); + expect($tasks)->toHaveCount(1)->and(array_key_exists('payload', $tasks[0]))->toBeFalse(); +}); + +test('quality task excludes foreign company department and current process conflict', function () { + $e = submittedEvaluation($this); + $other = receiptFixture(); + expect(qualityEvaluationTasks($this, $other['issuer']))->toBe([]); + $dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']); + $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + expect(qualityEvaluationTasks($this, $head))->toBe([]); + ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->reviewer->id, 'requisition_id' => $this->f['r']->id, + 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício no processo avaliado.']); + expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); + $this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('canReview', false)); +}); + +test('quality task keeps old pending evaluations visible through pagination without writing', function () { + $e = submittedEvaluation($this); + for ($i = 0; $i < 104; $i++) { + $copy = $e->replicate(); + $copy->operation_key = (string) Str::uuid(); + $copy->save(); + } + $before = AuditLog::query()->count(); + expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(105); + $this->get('/requisicoes/tarefas?module=purchases&search=AVA-')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supplier-evaluation:'.$e->id.':review:2')); + $this->get('/requisicoes/tarefas?module=purchases&search=AVA-&page=5')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 5)); + expect(AuditLog::query()->count())->toBe($before); +}); + +test('quality task blocks maintenance writes and expired memberships using current authority', function () { + $e = submittedEvaluation($this); + supplyGrant($this->reviewer, ['finance']); + DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); + $this->get('/fornecedores/avaliacoes')->assertInertia(fn (Assert $p) => $p->where('canCreate', false)); + $this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('canReview', false)); + $this->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 2, 'reason' => 'Tentativa com acesso apenas para manutenção.'])->assertForbidden(); + $this->post('/fornecedores/avaliacoes', [...$this->data, 'operation_key' => (string) Str::uuid()])->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['expires_at' => now()->subMinute()]); + expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and($e->refresh()->status)->toBe('review'); +}); + +test('quality task and commands reject a revoked role even with a previously loaded actor', function () { + $e = submittedEvaluation($this); + expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(1); + DB::table('users')->where('id', $this->reviewer->id)->update(['purchasing_role' => 'requester']); + expect(qualityEvaluationTasks($this, $this->reviewer->fresh()))->toBe([]); + $this->actingAs($this->reviewer); + try { + app(SupplierEvaluationWorkflow::class)->transition($this->reviewer, $e->id, 2, 'approve', 'Tentativa com instância anterior à revogação.'); + $this->fail('O papel revogado não pode decidir.'); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(403); + } + expect($e->refresh()->status)->toBe('review')->and($e->version)->toBe(2); +}); diff --git a/tests/Feature/SupplierImprovementTest.php b/tests/Feature/SupplierImprovementTest.php index d9c7518..a3b24f9 100644 --- a/tests/Feature/SupplierImprovementTest.php +++ b/tests/Feature/SupplierImprovementTest.php @@ -9,12 +9,14 @@ use App\Models\SupplierImprovement; use App\Models\User; use App\Services\RequisitionWorkflow; use App\Services\SupplierImprovementWorkflow; +use App\Services\WorkflowTaskInbox; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia as Assert; require_once __DIR__.'/../Support/ReceiptFixture.php'; +require_once __DIR__.'/../Support/SupplyFixture.php'; beforeEach(function () { $this->f = receiptFixture(); @@ -154,3 +156,79 @@ test('audit failure rolls a measure and its progress back without changing the e expect(fn () => app(SupplierImprovementWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $m->id, 'progress', ['version' => 1, ...$this->execution]))->toThrow(RuntimeException::class); expect($m->refresh()->version)->toBe(1)->and($m->result)->toBeNull()->and($this->evaluation->refresh()->status)->toBe('approved'); }); + +function improvementTasks(object $test, User $actor): array +{ + $test->actingAs($actor); + + return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), + fn ($row) => str_starts_with($row['id'], 'supplier-improvement:'))); +} + +test('improvement tasks follow assignment independent review return and closure without changing the approved source', function () { + $before = $this->evaluation->refresh()->getAttributes(); + $m = improvementDraft($this); + expect(improvementTasks($this, $this->planner))->toBe([]); + $tasks = improvementTasks($this, $this->f['issuer']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['due_on'])->toBe($this->data['due_on']) + ->and($tasks[0]['href'])->toBe('/fornecedores/avaliacoes/'.$this->evaluation->id.'#medida-'.$m->id); + $this->post($this->url.'/'.$m->id.'/submit', ['version' => 1, ...$this->execution])->assertRedirect(); + expect(improvementTasks($this, $this->f['issuer']))->toBe([]) + ->and(improvementTasks($this, $this->planner))->toBe([]); + $tasks = improvementTasks($this, $this->closer); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('supplier_improvement_review'); + $this->post($this->url.'/'.$m->id.'/return', ['version' => 2, 'reason' => 'Completar referência da evidência apresentada.'])->assertRedirect(); + expect(improvementTasks($this, $this->closer))->toBe([])->and(improvementTasks($this, $this->f['issuer']))->toHaveCount(1); + $this->post($this->url.'/'.$m->id.'/submit', ['version' => 3, ...$this->execution])->assertRedirect(); + $this->actingAs($this->closer)->post($this->url.'/'.$m->id.'/close', ['version' => 4, 'reason' => 'Conferência independente da execução e evidência.'])->assertRedirect(); + expect(improvementTasks($this, $this->closer))->toBe([])->and(improvementTasks($this, $this->f['issuer']))->toBe([]) + ->and($this->evaluation->refresh()->getAttributes())->toBe($before); +}); + +test('improvement tasks exclude other companies departments and current conflicts', function () { + improvementInReview($this); + $other = receiptFixture(); + expect(improvementTasks($this, $other['issuer']))->toBe([]); + $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']); + $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + expect(improvementTasks($this, $head))->toBe([]); + expect(improvementTasks($this, $this->closer))->toHaveCount(1); + ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->closer->id, 'requisition_id' => $this->f['r']->id, + 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício para testar o encaminhamento.']); + expect(improvementTasks($this, $this->closer))->toBe([]); +}); + +test('improvement tasks retain old pending measures beyond the first hundred and disclose no evidence', function () { + $m = improvementDraft($this); + for ($i = 0; $i < 104; $i++) { + $copy = $m->replicate(); + $copy->operation_key = (string) Str::uuid(); + $copy->save(); + } + $before = AuditLog::query()->count(); + $tasks = improvementTasks($this, $this->f['issuer']); + expect($tasks)->toHaveCount(105)->and($tasks[0]['id'])->toStartWith('supplier-improvement:'.$m->id.':') + ->and(array_key_exists('evidence', $tasks[0]))->toBeFalse()->and(AuditLog::query()->count())->toBe($before); +}); + +test('improvement task eligibility and page actions share the current role and approved evaluation', function () { + $m = improvementInReview($this); + expect(improvementTasks($this, $this->closer))->toHaveCount(1); + $this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canReview', true)); + DB::table('users')->where('id', $this->closer->id)->update(['purchasing_role' => 'requester']); + expect(improvementTasks($this, $this->closer->fresh()))->toBe([]); + $this->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa após revogação da função anterior.'])->assertForbidden(); + expect($m->refresh()->status)->toBe('review'); +}); + +test('improvement tasks keep maintenance accounts read only and expired memberships ineligible', function () { + $m = improvementInReview($this); + supplyGrant($this->closer, ['finance']); + DB::table('organization_memberships')->where('user_id', $this->closer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + expect(improvementTasks($this, $this->closer))->toBe([]); + $this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canReview', false)); + $this->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa de decisão com acesso de manutenção.'])->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->closer->id)->update(['expires_at' => now()->subMinute()]); + expect(improvementTasks($this, $this->closer))->toBe([]); + expect($m->refresh()->status)->toBe('review'); +}); diff --git a/tests/Feature/SupplierMaintenanceTest.php b/tests/Feature/SupplierMaintenanceTest.php index a43a06c..0754c17 100644 --- a/tests/Feature/SupplierMaintenanceTest.php +++ b/tests/Feature/SupplierMaintenanceTest.php @@ -1,6 +1,8 @@ count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]); expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1); }); +test('supplier task connects independent review current forms and versioned edits without writing on reads', function () { + $this->supplier->update(['status' => 'pending']); + supplyGrant($this->buyer, ['procurement', 'finance']); + supplyGrant($this->finance, ['finance']); + $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', false)); + $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.task', 'supplier_review')->where('tasks.data.0.href', $this->url.'#supplier-review') + ->missing('tasks.data.0.email')->missing('tasks.data.0.beneficiaries')->where('tasks.data.0.due_on', null)); + $before = [Supplier::query()->count(), AuditLog::query()->count()]; + $this->get($this->url)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->where('canReview', true)->where('supplier.version', 1)); + expect([Supplier::query()->count(), AuditLog::query()->count()])->toBe($before); + $this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Conferência independente da ficha sintética.'])->assertRedirect(); + $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->buyer)->put($this->url, [...maintenanceSupplierData(), 'name' => 'Ficha revista para novo uso', 'version' => 2])->assertRedirect(); + $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.id', 'supplier:'.$this->supplier->id.':review:3')); + $this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Tentativa antiga que não deve sobrescrever a ficha.'])->assertUnprocessable(); +}); + +test('supplier task excludes the last editor and preserves activation checks for blocked or expired sources', function () { + $this->supplier->update(['status' => 'pending', 'last_changed_by' => $this->finance->id]); + $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Não pode conferir a alteração que efectuou.'])->assertForbidden(); + $this->supplier->update(['last_changed_by' => $this->buyer->id, 'blocked' => true]); + $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); + $this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'O impedimento não pode ser ignorado na activação.'])->assertUnprocessable(); + $this->supplier->update(['blocked' => false, 'next_review' => today()->subDay()]); + $this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'A vigência deve ser renovada na ficha de origem.'])->assertUnprocessable(); + $this->post($this->url.'/validar', ['version' => 1, 'status' => 'suspended', 'comment' => 'Suspensão sintética sem forçar a activação vencida.'])->assertRedirect(); + $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); +}); + +test('supplier task retains older pending records across all task pages and direct source links', function () { + $this->supplier->update(['status' => 'pending']); + for ($index = 1; $index < 105; $index++) { + Supplier::query()->create([...maintenanceSupplierData(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'last_changed_by' => $this->buyer->id, + 'name' => 'Ficha sintética '.$index, 'nuit' => str_pad((string) $index, 9, '0', STR_PAD_LEFT), 'status' => 'pending']); + } + $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25) + ->where('tasks.data.0.href', $this->url.'#supplier-review')); + $this->get('/requisicoes/tarefas?module=purchases&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); + $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', true)); +}); + +test('supplier task preserves tenant maintenance expiry and fresh actor authority on native commands', function () { + $this->supplier->update(['status' => 'pending']); + $foreignTenant = Tenant::query()->create(['name' => 'Outra empresa do ensaio']); + $foreign = User::factory()->create(['tenant_id' => $foreignTenant->id, 'purchasing_role' => PurchasingRole::Finance]); + $this->actingAs($foreign)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($this->url)->assertNotFound(); + supplyGrant($this->finance, ['finance']); + supplyGrant($this->buyer, ['procurement']); + DB::table('organization_memberships')->where('user_id', $this->finance->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', false)); + $this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Manutenção não deve decidir no percurso de negócio.'])->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->buyer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->actingAs($this->buyer)->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->finance->id)->update(['expires_at' => now()->subMinute()]); + $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertForbidden(); + expect(app(SupplierParticipation::class)->canReview($this->finance, $this->supplier))->toBeFalse(); +}); +test('supplier task preparation cannot borrow a cached role revoked after form validation', function () { + $input = SaveSupplierRequest::create('/fornecedores', 'POST', [...maintenanceSupplierData(), 'nuit' => '987654321']); + $input->setContainer($this->app)->setRedirector($this->app['redirect']); + $input->setUserResolver(fn () => $this->buyer); + $input->validateResolved(); + DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => PurchasingRole::Requester->value]); + $before = [Supplier::query()->count(), AuditLog::query()->count()]; + expect(fn () => app(SupplierController::class)->store($input, app(RequisitionWorkflow::class))) + ->toThrow(HttpException::class); + expect([Supplier::query()->count(), AuditLog::query()->count()])->toBe($before); +}); diff --git a/tests/Feature/SupplierPayableIntegrationTest.php b/tests/Feature/SupplierPayableIntegrationTest.php index 658a285..682a319 100644 --- a/tests/Feature/SupplierPayableIntegrationTest.php +++ b/tests/Feature/SupplierPayableIntegrationTest.php @@ -63,7 +63,15 @@ test('activation does not grant permissions and source queries remain tenant sco $this->actingAs($foreign); expect(fn () => app(SupplierPayables::class)->principal($this->f['invoice']))->toThrow(HttpException::class); config(['common-payables.enabled' => false]); - expect(SupplierPayables::enabled())->toBeFalse(); + // Owned PostgreSQL QA fixtures have an explicit environment override; normal + // installations still require the configured activation flag. + $environment = app()->environment(); + app()->detectEnvironment(fn () => 'local'); + try { + expect(SupplierPayables::enabled())->toBeFalse(); + } finally { + app()->detectEnvironment(fn () => $environment); + } }); test('the shared schema preserves histories and refuses repeated or partial installation', function () { diff --git a/tests/Feature/SupplierResponseTest.php b/tests/Feature/SupplierResponseTest.php index 1fd24b9..8018e1b 100644 --- a/tests/Feature/SupplierResponseTest.php +++ b/tests/Feature/SupplierResponseTest.php @@ -9,12 +9,15 @@ use App\Models\SupplierResponse; use App\Models\User; use App\Services\RequisitionWorkflow; use App\Services\SupplierResponseWorkflow; +use App\Services\WorkflowTaskInbox; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia as Assert; +use Symfony\Component\HttpKernel\Exception\HttpException; require_once __DIR__.'/../Support/ReceiptFixture.php'; +require_once __DIR__.'/../Support/SupplyFixture.php'; beforeEach(function () { $this->f = receiptFixture(); @@ -158,3 +161,86 @@ test('failed audit rolls the response edit back without changing the approved ev expect(fn () => app(SupplierResponseWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $record->id, 'save', ['version' => 1, 'payload' => [...$this->payload, 'title' => 'Não deve persistir']]))->toThrow(RuntimeException::class); expect($record->refresh()->version)->toBe(1)->and($record->payload['title'])->toBe($this->payload['title'])->and($this->evaluation->refresh()->getAttributes())->toBe($before); }); + +function qualityResponseTasks(object $test, User $actor): array +{ + $test->actingAs($actor); + + return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), + fn ($row) => str_starts_with($row['id'], 'supplier-response:'))); +} + +test('response task follows preparation review return and decision preserving approved source', function () { + $before = $this->evaluation->refresh()->getAttributes(); + $record = responseDraft($this); + expect(qualityResponseTasks($this, $this->f['issuer']))->toHaveCount(1)->and(qualityResponseTasks($this, $this->reviewer))->toBe([]); + $this->actingAs($this->f['issuer'])->post($this->url.'/'.$record->id.'/submit', ['version' => 1])->assertRedirect(); + expect(qualityResponseTasks($this, $this->f['issuer']))->toBe([]); + $tasks = qualityResponseTasks($this, $this->reviewer); + expect($tasks)->toHaveCount(1)->and($tasks[0]['href'])->toBe($this->base.'#contraditorio-'.$record->id)->and($tasks[0]['due_on'])->toBeNull(); + $this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.records.0.canReview', true)); + $this->post($this->url.'/'.$record->id.'/return', ['version' => 2, 'reason' => 'Completar a referência da origem dos argumentos.'])->assertRedirect(); + expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toHaveCount(1); + $this->post($this->url.'/'.$record->id.'/submit', ['version' => 3])->assertRedirect(); + $this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/decide', ['version' => 4, ...$this->decision])->assertRedirect(); + expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toBe([]) + ->and($this->evaluation->fresh()->getAttributes())->toBe($before); +}); + +test('response task excludes every contributor and does not disclose arguments or evidence', function () { + $record = responseDraft($this); + $this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/save', ['version' => 1, 'payload' => [...$this->payload, 'statement' => 'Argumentos actualizados por outro preparador.']])->assertRedirect(); + expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(1); + $this->post($this->url.'/'.$record->id.'/submit', ['version' => 2])->assertRedirect(); + expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toBe([]); + $independent = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); + $tasks = qualityResponseTasks($this, $independent); + expect($tasks)->toHaveCount(1)->and(array_key_exists('payload', $tasks[0]))->toBeFalse()->and(array_key_exists('decision', $tasks[0]))->toBeFalse(); +}); + +test('response task excludes foreign company department current conflicts and maintenance', function () { + $record = responseInReview($this); + $other = receiptFixture(); + expect(qualityResponseTasks($this, $other['issuer']))->toBe([]); + $dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']); + $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + expect(qualityResponseTasks($this, $head))->toBe([]); + supplyGrant($this->reviewer, ['finance']); + DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + expect(qualityResponseTasks($this, $this->reviewer))->toBe([]); + $this->post($this->url.'/'.$record->id.'/decide', ['version' => 2, ...$this->decision])->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => false]); + ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->reviewer->id, 'requisition_id' => $this->f['r']->id, + 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício nos argumentos do fornecedor.']); + expect(qualityResponseTasks($this, $this->reviewer))->toBe([]); + $this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.records.0.canReview', false)); +}); + +test('response task keeps old pending records beyond one hundred and pages them without writes', function () { + $record = responseInReview($this); + for ($i = 0; $i < 104; $i++) { + $copy = $record->replicate(); + $copy->operation_key = (string) Str::uuid(); + $copy->save(); + } + $before = AuditLog::query()->count(); + expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(105); + $this->get('/requisicoes/tarefas?module=purchases&search=CTR-')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supplier-response:'.$record->id.':review:2')); + $this->get('/requisicoes/tarefas?module=purchases&search=CTR-&page=5')->assertInertia(fn (Assert $p) => $p->has('tasks.data', 5)); + expect(AuditLog::query()->count())->toBe($before); +}); + +test('response task and commands reject a revoked role even with a previously loaded actor', function () { + $record = responseInReview($this); + expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(1); + DB::table('users')->where('id', $this->reviewer->id)->update(['purchasing_role' => 'requester']); + expect(qualityResponseTasks($this, $this->reviewer->fresh()))->toBe([]); + $this->actingAs($this->reviewer); + try { + app(SupplierResponseWorkflow::class)->execute($this->reviewer, $this->evaluation->id, $record->id, 'decide', ['version' => 2, ...$this->decision]); + $this->fail('O papel revogado não pode decidir.'); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(403); + } + expect($record->refresh()->status)->toBe('review')->and($record->version)->toBe(2); +}); diff --git a/tests/Feature/SupplyCoordinationTest.php b/tests/Feature/SupplyCoordinationTest.php index 858000d..021ca70 100644 --- a/tests/Feature/SupplyCoordinationTest.php +++ b/tests/Feature/SupplyCoordinationTest.php @@ -2,11 +2,16 @@ use App\Models\StockItem; use App\Models\SupplyRecord; +use App\Models\User; +use App\Models\Warehouse; use App\Services\ReplenishmentProjection; use App\Services\StockWorkflow; use App\Services\SupplyCoordination; use App\Services\SupplyPlanning; +use App\Services\WorkflowTaskInbox; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Inertia\Testing\AssertableInertia as Assert; require_once __DIR__.'/../Support/ReceiptFixture.php'; require_once __DIR__.'/../Support/SupplyFixture.php'; @@ -23,6 +28,139 @@ function reserveData(array $f, string $reference = 'NEC-1', int $quantity = 3000 return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'quantity_milli' => $quantity, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => $reference]; } +test('task deadline followup changes with time and native cancellation without new decisions or stock writes', function () { + $this->freezeTime(); + $service = app(SupplyCoordination::class); + $record = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'PRAZO-NATIVO'))); + $counts = [DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()]; + $this->get('/requisicoes/tarefas?module=supply&due=today')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 1)->where('tasks.data.0.due_status', 'today')); + $this->travel(1)->days(); + $this->get('/requisicoes/tarefas?module=supply&due=overdue')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 1) + ->where('tasks.data.0.href', '/compras/fornecimento?view=reservations&record_id='.$record->id.'#supply-record-'.$record->id)); + expect([DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()])->toBe($counts); + $service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $record)); + $this->get('/requisicoes/tarefas?module=supply&due=overdue')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 0)->where('deadlineCounts.overdue', 0)); + expect($record->fresh()->state)->toBe('active')->and(DB::table('stock_movements')->count())->toBe($counts[2]); +}); + +function coordinationCsvRows(string $csv): array +{ + $stream = fopen('php://temp', 'w+'); + fwrite($stream, $csv); + rewind($stream); + $rows = []; + while (($row = fgetcsv($stream, null, ';', '"', '')) !== false) { + $rows[] = $row; + } + fclose($stream); + + return $rows; +} + +test('coordination export includes all filtered latest records across pages and preserves exact focus', function () { + $service = app(SupplyCoordination::class); + $old = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'EXPORT-OLD'))); + for ($i = 0; $i < 26; $i++) { + $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'EXPORT-NEW-'.$i))); + } + $cancelled = $service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $old)); + $counts = [DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()]; + $response = $this->get('/compras/fornecimento.csv?view=reservations&record_search=export&records_page=2')->assertOk() + ->assertHeader('Content-Type', 'text/csv; charset=UTF-8')->assertHeader('Cache-Control', 'max-age=0, no-store, private'); + $rows = coordinationCsvRows($response->streamedContent()); + expect($rows)->toHaveCount(30); + $matches = array_values(array_filter(array_slice($rows, 3), fn ($row) => $row[0] === $old->record_key)); + expect($matches)->toHaveCount(1)->and($matches[0][1])->toBe('2')->and($matches[0][2])->toBe('cancelled') + ->and($matches[0][15])->toContain('record_id='.$cancelled->id); + $active = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations&record_state=active')->assertOk()->streamedContent()); + expect($active)->toHaveCount(29); + $focused = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations&record_search=NO-MATCH&record_state=active&record_id='.$old->id)->assertOk()->streamedContent()); + expect($focused)->toHaveCount(4)->and($focused[3][0])->toBe($old->record_key)->and($focused[3][2])->toBe('cancelled'); + expect([DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($counts); +}); + +test('coordination export reuses native partial fulfilment and protects formula-like references', function () { + $this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect(); + $reference = "\t=HYPERLINK(\"synthetic\")"; + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, $reference))); + $issue = coordinationIssue($this, '20.000'); + app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 20000], 'fulfil', $record)); + $counts = [DB::table('supply_records')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()]; + $rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations')->assertOk()->streamedContent()); + expect($rows[3][6])->toStartWith("'")->and($rows[3][8])->toBe('30')->and($rows[3][11])->toBe('10'); + expect([DB::table('supply_records')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($counts); +}); + +test('coordination export validates scope and uses existing read permissions without grants', function () { + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f))); + $this->getJson('/compras/fornecimento.csv?view=reservations&record_state=approved')->assertUnprocessable(); + $this->getJson('/compras/fornecimento.csv?view=reservations&record_search='.str_repeat('A', 101))->assertUnprocessable(); + $this->getJson('/compras/fornecimento.csv?view=alternatives&record_id='.$record->id)->assertUnprocessable(); + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['supply_read']); + $this->actingAs($foreign['receiver'])->getJson('/compras/fornecimento.csv?view=reservations&record_id='.$record->id)->assertUnprocessable(); + $rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations')->assertOk()->streamedContent()); + expect($rows)->toHaveCount(3); + $this->actingAs($this->f['otherReceiver'])->get('/compras/fornecimento.csv?view=reservations')->assertForbidden(); + $this->actingAs($this->f['receiver']); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true]); + $this->get('/compras/fornecimento.csv?view=reservations')->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]); + $this->get('/compras/fornecimento.csv?view=reservations')->assertForbidden(); +}); + +test('coordination export keeps delivery and technical alternative meanings distinct', function () { + $this->actingAs($this->f['issuer']); + $delivery = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f))); + $rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=deliveries')->assertOk()->streamedContent()); + expect($rows)->toHaveCount(4)->and($rows[3][0])->toBe($delivery->record_key)->and($rows[3][7])->toBe($this->f['order']->number)->and($rows[3][11])->toBe('100'); + $alternative = taskAlternative($this); + $this->actingAs($this->f['receiver']); + $rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=alternatives')->assertOk()->streamedContent()); + expect($rows)->toHaveCount(4)->and($rows[3][0])->toBe($alternative->record_key)->and($rows[3][2])->toBe('draft') + ->and($rows[3][11])->toBe('')->and($rows[3][12])->toBe($alternative->snapshot['substitute']['code']); +}); + +test('coordination record filters find old sources before pagination and never resurrect previous states', function () { + $service = app(SupplyCoordination::class); + $old = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'NEC-ANTIGA-LOCALIZAR'))); + for ($i = 0; $i < 26; $i++) { + $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'NEC-RECENTE-'.$i))); + } + $cancelled = $service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $old)); + $counts = [DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()]; + $this->get('/compras/fornecimento?view=reservations&record_search=nec-antiga')->assertInertia(fn (Assert $p) => $p + ->where('recordPagination.total', 1)->has('records', 1)->where('records.0.id', $cancelled->id) + ->where('recordFilters.record_search', 'nec-antiga')->has('history.'.$old->record_key, 2)); + $this->get('/compras/fornecimento?view=reservations&record_search=nec-antiga&record_state=active')->assertInertia(fn (Assert $p) => $p->where('recordPagination.total', 0)->has('records', 0)); + $this->get('/compras/fornecimento?view=reservations&record_state=active')->assertInertia(fn (Assert $p) => $p + ->where('recordPagination.total', 26)->has('records', 25)->where('recordPagination.next_page_url', fn ($url) => str_contains($url, 'record_state=active'))); + $this->get('/compras/fornecimento?view=reservations&record_state=active&record_search=NO-MATCH&record_id='.$old->id)->assertInertia(fn (Assert $p) => $p + ->where('recordPagination', null)->where('focusChanged', true)->where('records.0.id', $cancelled->id)); + foreach ([$this->f['stock']->code, $this->f['stock']->name, $this->f['warehouse']->name] as $search) { + $this->get('/compras/fornecimento?view=reservations&record_search='.urlencode(mb_strtolower($search)))->assertInertia(fn (Assert $p) => $p->where('recordPagination.total', 27)); + } + $this->get('/compras/fornecimento?view=reservations&record_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('records', 0)); + expect([DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($counts); + expect($old->fresh()->state)->toBe('active'); + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['supply_read']); + $this->actingAs($foreign['receiver'])->get('/compras/fornecimento?view=reservations&record_search=nec-antiga')->assertInertia(fn (Assert $p) => $p->has('records', 0)->where('canPrepare', false)); +}); + +test('coordination record filters enforce per-process states and bounded queries', function (string $view, string $state) { + $this->getJson('/compras/fornecimento?view='.$view.'&record_state='.$state)->assertUnprocessable(); + $this->getJson('/compras/fornecimento?view='.$view.'&record_search='.str_repeat('A', 101))->assertUnprocessable(); +})->with([['reservations', 'approved'], ['deliveries', 'fulfilled'], ['alternatives', 'active']]); + +test('coordination record filters reuse issued order number without receiving its goods', function () { + $this->actingAs($this->f['issuer']); + $record = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f))); + $counts = [DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('supply_records')->count()]; + $this->get('/compras/fornecimento?view=deliveries&record_search='.urlencode(mb_strtolower($this->f['order']->number)))->assertInertia(fn (Assert $p) => $p->has('records', 1)->where('records.0.id', $record->id)); + expect([DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('supply_records')->count()])->toBe($counts); +}); + function deliveryData(array $f): array { return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'purchase_order_item_id' => $f['line']->id, 'quantity_milli' => 100000, @@ -35,6 +173,101 @@ function coordinationIssue($test, string $quantity): object 'responsible_id' => $test->f['receiver']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'reason' => 'Satisfação da necessidade no ensaio.']); } +function coordinationSourceRequest(object $test, string $number, string $status = 'approved'): array +{ + $request = $test->f['r']->replicate(); + $request->number = $number; + $request->status = $status; + $request->save(); + $line = $test->f['r']->items()->firstOrFail()->replicate(); + $line->requisition_id = $request->id; + $line->catalogue_snapshot = ['stock_item_id' => $test->f['stock']->id]; + $line->save(); + + return [$request, $line]; +} + +test('supply line search reaches an older approved request and conserves its selected canonical line', function () { + [$old, $line] = coordinationSourceRequest($this, 'REQ-LOOKUP-OLD'); + for ($i = 0; $i < 100; $i++) { + coordinationSourceRequest($this, 'REQ-LOOKUP-NEW-'.$i); + } + $before = [$old->fresh()->getAttributes(), $line->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()]; + $this->get('/compras/fornecimento?view=reservations')->assertInertia(fn (Assert $p) => $p->has('requestLines', 100) + ->where('requestLines', fn ($rows) => collect($rows)->where('id', $line->id)->isEmpty())); + $this->get('/compras/fornecimento?view=reservations&line_search=req-lookup-old')->assertInertia(fn (Assert $p) => $p->has('requestLines', 1)->where('requestLines.0.id', $line->id)); + $this->get('/compras/fornecimento?view=reservations&line_search=NO-MATCH-FICTICIO&selected_request_line_id='.$line->id)->assertInertia(fn (Assert $p) => $p->has('requestLines', 1)); + expect([$old->fresh()->getAttributes(), $line->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($before); + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput([...reserveData($this->f), 'requisition_item_id' => $line->id])); + expect($record->data['reference'])->toBe($old->number.' / '.$line->id)->and(DB::table('stock_movements')->count())->toBe($before[3]); +}); + +test('supply line search reuses issued order lines without issuing another order or receiving stock', function () { + $this->actingAs($this->f['issuer']); + $before = [DB::table('purchase_orders')->count(), DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()]; + $this->get('/compras/fornecimento?view=deliveries&line_search='.urlencode(mb_strtolower($this->f['order']->number)))->assertInertia(fn (Assert $p) => $p->has('orderLines', 1)->where('orderLines.0.id', $this->f['line']->id)); + $this->get('/compras/fornecimento?view=deliveries&line_search=NO-MATCH-FICTICIO')->assertInertia(fn (Assert $p) => $p->has('orderLines', 0)); + $this->get('/compras/fornecimento?view=deliveries&line_search=NO-MATCH-FICTICIO&selected_order_line_id='.$this->f['line']->id)->assertInertia(fn (Assert $p) => $p->has('orderLines', 1)); + expect([DB::table('purchase_orders')->count(), DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($before); + $record = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f))); + expect($record->data['purchase_order_item_id'])->toBe($this->f['line']->id) + ->and([DB::table('purchase_orders')->count(), DB::table('goods_receipts')->count(), DB::table('stock_movements')->count()])->toBe(array_slice($before, 0, 3)); +}); + +test('supply line search rejects invalid input foreign lines and unapproved request sources', function () { + [$draft, $line] = coordinationSourceRequest($this, 'REQ-LOOKUP-DRAFT', 'draft'); + $this->get('/compras/fornecimento?view=reservations&line_search=REQ-LOOKUP-DRAFT&selected_request_line_id='.$line->id)->assertInertia(fn (Assert $p) => $p->has('requestLines', 0)); + $this->getJson('/compras/fornecimento?view=reservations&line_search='.str_repeat('A', 101))->assertUnprocessable(); + $this->get('/compras/fornecimento?view=reservations&line_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('requestLines', 0)); + supplyGrant($this->f['otherReceiver'], ['supply_read']); + $this->actingAs($this->f['otherReceiver'])->get('/compras/fornecimento?view=reservations&selected_request_line_id='.$line->id)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('requestLines', 0)); + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['supply_reserve', 'supply_delivery']); + $this->actingAs($foreign['receiver'])->getJson('/compras/fornecimento?view=reservations&selected_request_line_id='.$line->id)->assertUnprocessable(); + $this->getJson('/compras/fornecimento?view=deliveries&selected_order_line_id='.$this->f['line']->id)->assertUnprocessable(); +}); + +test('reservation source search reaches an old issue and preserves selection without repeating its movement', function () { + $this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect(); + $old = coordinationIssue($this, '0.001'); + for ($i = 0; $i < 100; $i++) { + coordinationIssue($this, '0.001'); + } + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f))); + $url = '/compras/fornecimento?view=reservations&issue_record_id='.$record->id; + $before = [DB::table('stock_movements')->count(), DB::table('audit_logs')->count(), $old->fresh()->getAttributes()]; + $this->get('/compras/fornecimento?view=reservations')->assertInertia(fn (Assert $page) => $page->has('operations', 100) + ->where('operations', fn ($rows) => collect($rows)->where('id', $old->id)->isEmpty())); + $this->get($url.'&issue_search='.urlencode(mb_strtolower($old->reference)))->assertInertia(fn (Assert $page) => $page->has('operations', 1)->where('operations.0.id', $old->id)); + $this->get($url.'&issue_search=NO-MATCH-FICTICIO&selected_issue_id='.$old->id)->assertInertia(fn (Assert $page) => $page->has('operations', 1)->where('operations.0.id', $old->id)); + expect([DB::table('stock_movements')->count(), DB::table('audit_logs')->count(), $old->fresh()->getAttributes()])->toBe($before); + app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $old->id, 'quantity_milli' => 1], 'fulfil', $record)); + expect(DB::table('stock_movements')->count())->toBe($before[0]); +}); + +test('reservation source search respects article warehouse current state permissions and company', function () { + $this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect(); + $issue = coordinationIssue($this, '1.000'); + $service = app(SupplyCoordination::class); + $item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'OTHER-SEARCH', 'name' => 'Outro artigo de teste', 'unit' => 'cx']); + $other = $service->save($this->f['receiver'], 'reservation', supplyInput([...reserveData($this->f), 'stock_item_id' => $item->id])); + $this->get('/compras/fornecimento?view=reservations&issue_record_id='.$other->id.'&selected_issue_id='.$issue->id)->assertInertia(fn (Assert $page) => $page->has('operations', 0)); + $warehouse = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'OTHER-SEARCH', 'name' => 'Outro armazém de teste']); + $otherPlace = $service->save($this->f['receiver'], 'reservation', supplyInput([...reserveData($this->f), 'warehouse_id' => $warehouse->id])); + $this->get('/compras/fornecimento?view=reservations&issue_record_id='.$otherPlace->id.'&selected_issue_id='.$issue->id)->assertInertia(fn (Assert $page) => $page->has('operations', 0)); + $record = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f))); + $url = '/compras/fornecimento?view=reservations&issue_record_id='.$record->id; + $this->getJson($url.'&issue_search='.str_repeat('A', 101))->assertUnprocessable(); + supplyGrant($this->f['otherReceiver'], ['supply_read']); + $this->actingAs($this->f['otherReceiver'])->get($url)->assertInertia(fn (Assert $page) => $page->where('canPrepare', false)->has('operations', 0)); + $this->actingAs($this->f['receiver']); + $service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $record)); + $this->get($url)->assertForbidden(); + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['supply_reserve']); + $this->actingAs($foreign['receiver'])->getJson($url)->assertUnprocessable(); +}); + test('reservations enter calendar once and their allocations reuse existing issues without changing stock', function () { $this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect(); $input = supplyInput(reserveData($this->f)); @@ -129,3 +362,152 @@ test('coordination permission never grants unrelated workflow commands or crosse $this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/reservation', supplyInput(reserveData($foreign)))->assertNotFound(); $this->get('/compras/fornecimento?view=reservations')->assertOk(); }); +function taskAlternative($test, string $compatibility = 'equivalent'): SupplyRecord +{ + $substitute = StockItem::query()->create(['tenant_id' => $test->f['tenant']->id, 'created_by' => $test->f['issuer']->id, 'code' => 'TASK-SUB', 'name' => 'Alternativa sintética', 'unit' => 'cx']); + $test->actingAs($test->f['issuer']); + + return app(SupplyCoordination::class)->save($test->f['issuer'], 'alternative', supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id, + 'substitute_id' => $substitute->id, 'application' => 'Aplicação técnica de ensaio', 'compatibility' => $compatibility, 'evidence' => 'Evidência técnica sintética para validar encaminhamento.', + 'factor_numerator' => 1, 'factor_denominator' => 1, 'valid_until' => today()->addMonth()->toDateString()])); +} + +test('alternative task reaches the independent technical reviewer and a stale link shows the current decision', function () { + $draft = taskAlternative($this); + supplyGrant($this->f['issuer'], ['supply_technical_review']); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get('/compras/fornecimento?view=alternatives&record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'alternative_review') + ->where('tasks.data.0.href', '/compras/fornecimento?view=alternatives&record_id='.$draft->id.'#supply-record-'.$draft->id)->missing('tasks.data.0.snapshot')); + $counts = [SupplyRecord::query()->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()]; + $this->get('/compras/fornecimento?view=alternatives&record_id='.$draft->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canApprove', true)); + expect([SupplyRecord::query()->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($counts); + $approved = app(SupplyCoordination::class)->save($this->f['receiver'], 'alternative', supplyInput([], 'approve', $draft)); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get('/compras/fornecimento?view=alternatives&record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canSuspend', true)); +}); + +test('alternative task preserves source checks incomplete analysis and independence of all preparers', function () { + $draft = taskAlternative($this, 'pending'); + $this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertUnprocessable(); + $this->f['stock']->update(['name' => 'Nova especificação do ensaio']); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); + $returned = app(SupplyCoordination::class)->save($this->f['receiver'], 'alternative', supplyInput([], 'return', $draft)); + expect($returned->snapshot['item'])->toBe($draft->snapshot['item']); + supplyGrant($this->f['receiver'], ['supply_technical_prepare']); + $updated = app(SupplyCoordination::class)->save($this->f['receiver'], 'alternative', supplyInput([...$draft->data, 'compatibility' => 'equivalent'], 'save', $returned)); + supplyGrant($this->f['issuer'], ['supply_technical_review']); + foreach ([$this->f['issuer'], $this->f['receiver']] as $user) { + expect(app(SupplyCoordination::class)->canReviewAlternative($user, $updated))->toBeFalse(); + } + supplyGrant($this->f['otherReceiver'], ['supply_review']); + $this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + supplyGrant($this->f['otherReceiver'], ['supply_technical_review']); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); +}); + +test('alternative task keeps company type maintenance and grant expiry boundaries', function () { + $draft = taskAlternative($this); + $path = '/compras/fornecimento?view=alternatives&record_id='.$draft->id; + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['supply_technical_review']); + $this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable(); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=calendar&record_id='.$draft->id)->assertUnprocessable(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + expect(app(SupplyCoordination::class)->canReviewAlternative($this->f['receiver'], $draft))->toBeFalse(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/requisicoes/tarefas?module=supply')->assertForbidden(); +}); + +function coordinationTasks(object $test, User $actor): array +{ + $test->actingAs($actor); + + return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), fn ($row) => str_starts_with($row['id'], 'coordination:'))); +} + +test('coordination task follows planned reservation partial actual issues and completion without duplicating stock', function () { + $this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect(); + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f))); + $tasks = coordinationTasks($this, $this->f['receiver']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['due_on'])->toBe(today()->toDateString())->and($tasks[0]['task'])->toBe('reservation_followup'); + expect(coordinationTasks($this, $this->f['issuer']))->toBe([]); + $this->actingAs($this->f['receiver']); + $issue = coordinationIssue($this, '20.000'); + $partial = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 20000], 'fulfil', $record)); + expect(coordinationTasks($this, $this->f['receiver']))->toHaveCount(1) + ->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $record, 'fulfil'))->toBeFalse(); + $this->get('/compras/fornecimento?view=reservations&record_id='.$record->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $partial->id)->where('records.0.canFulfil', true)); + $second = coordinationIssue($this, '10.000'); + $fulfilled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $second->id, 'quantity_milli' => 10000], 'fulfil', $partial)); + expect(coordinationTasks($this, $this->f['receiver']))->toBe([]) + ->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $fulfilled, 'fulfil'))->toBeFalse() + ->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(70000) + ->and($record->fresh()->state)->toBe('active'); +}); + +test('coordination task follows confirmed delivery partial and full native receipts', function () { + $this->actingAs($this->f['issuer']); + $record = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f))); + $tasks = coordinationTasks($this, $this->f['issuer']); + expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('delivery_followup')->and($tasks[0]['due_on'])->toBe(today()->addWeek()->toDateString()); + expect(coordinationTasks($this, $this->f['receiver']))->toBe([]); + foreach (['20.000', '80.000'] as $quantity) { + $receipt = receiptPayload($this->f, $quantity); + unset($receipt['items'][0]['stock_item_id']); + $this->actingAs($this->f['receiver'])->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', $receipt)->assertRedirect(); + expect(coordinationTasks($this, $this->f['issuer']))->toHaveCount($quantity === '20.000' ? 1 : 0); + } + $this->actingAs($this->f['receiver']); + expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(100000) + ->and($record->fresh()->state)->toBe('active'); +}); + +test('coordination task cancellation removes the pending task and preserves the original forecast', function () { + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f))); + $before = $record->fresh()->getAttributes(); + $input = supplyInput([], 'cancel', $record); + $cancelled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', $input); + expect(app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', $input)->id)->toBe($cancelled->id) + ->and(coordinationTasks($this, $this->f['receiver']))->toBe([]) + ->and($record->fresh()->getAttributes())->toBe($before) + ->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $cancelled, 'cancel'))->toBeFalse() + ->and(DB::table('stock_movements')->count())->toBe(0); +}); + +test('coordination task respects company maintenance role expiry and focus type boundaries', function () { + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f))); + $foreign = receiptFixture(); + supplyGrant($foreign['receiver'], ['supply_reserve']); + expect(coordinationTasks($this, $foreign['receiver']))->toBe([]); + $this->getJson('/compras/fornecimento?view=reservations&record_id='.$record->id)->assertUnprocessable(); + $this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=deliveries&record_id='.$record->id)->assertUnprocessable(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + expect(coordinationTasks($this, $this->f['receiver']))->toBe([]); + $this->postJson('/compras/fornecimento/reservation', supplyInput([], 'cancel', $record))->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + expect(coordinationTasks($this, $this->f['receiver']))->toBe([]); + $this->get('/requisicoes/tarefas?module=supply')->assertForbidden(); + expect($record->fresh()->state)->toBe('active'); +}); + +test('coordination task and general pagination retain old pending needs beyond one hundred', function () { + $record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f))); + for ($i = 0; $i < 104; $i++) { + $copy = $record->replicate(); + $copy->record_key = (string) Str::uuid(); + $copy->operation_key = (string) Str::uuid(); + $copy->data = [...$copy->data, 'reference' => 'NEC-OLD-'.$i]; + $copy->created_at = $record->created_at; + $copy->save(); + } + $before = DB::table('audit_logs')->count(); + expect(coordinationTasks($this, $this->f['receiver']))->toHaveCount(105); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'coordination:'.$record->id.':followup')); + $this->get('/compras/fornecimento?view=reservations')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 105)->has('records', 25)); + $this->get('/compras/fornecimento?view=reservations&records_page=5')->assertInertia(fn (Assert $page) => $page->has('records', 5)->where('records.4.id', $record->id)); + $this->get('/compras/fornecimento?view=reservations&record_id='.$record->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $record->id)->where('recordPagination', null)); + expect(DB::table('audit_logs')->count())->toBe($before)->and(DB::table('stock_movements')->count())->toBe(0); +}); diff --git a/tests/Feature/SupplyHistoryTest.php b/tests/Feature/SupplyHistoryTest.php index 99452b6..e3c0112 100644 --- a/tests/Feature/SupplyHistoryTest.php +++ b/tests/Feature/SupplyHistoryTest.php @@ -2,6 +2,8 @@ use App\Models\StockMovement; use App\Models\SupplyRecord; +use App\Models\Tenant; +use App\Models\User; use App\Models\Warehouse; use App\Services\ReceiptWorkflow; use App\Services\ReplenishmentProjection; @@ -10,6 +12,7 @@ use App\Services\StockWorkflow; use App\Services\SupplyHistory; use App\Services\SupplyPlanning; use Carbon\CarbonImmutable; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia as Assert; @@ -63,6 +66,66 @@ function historyPolicy($test, SupplyRecord $history, array $extra = []): SupplyR return app(SupplyPlanning::class)->latest('policy')->sole(); } +test('history source search finds an older approved period before the recent choice limit', function () { + $old = approvedHistory($this); + $this->actingAs($this->f['issuer']); + $draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput([...historyData($this->f), 'period_from' => '2026-09-01', 'period_until' => '2026-09-30'])); + $this->actingAs($this->f['receiver']); + $recent = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput(['confirm_scope' => true], 'approve', $draft)); + for ($i = 0; $i < 99; $i++) { + $copy = $recent->replicate(); + $copy->record_key = (string) Str::uuid(); + $copy->operation_key = (string) Str::uuid(); + $copy->created_at = $recent->created_at; + $copy->save(); + } + $query = '/compras/fornecimento?view=calendar&history_stock_item_id='.$this->f['stock']->id.'&history_warehouse_id='.$this->f['warehouse']->id.'&history_purpose=internal'; + $before = [$old->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()]; + $this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $p) => $p->has('histories', 100)->where('histories', fn ($rows) => collect($rows)->where('key', $old->record_key)->isEmpty())); + $this->get($query.'&history_period_from=2026-10-01&history_period_until=2026-10-30')->assertInertia(fn (Assert $p) => $p->has('histories', 1)->where('histories.0.key', $old->record_key)); + $this->get($query.'&history_period_until=2026-09-30&selected_history_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p->has('histories', 101)); + $this->get(str_replace('history_purpose=internal', 'history_purpose=resale', $query).'&selected_history_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p->has('histories', 0)); + expect([$old->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()])->toBe($before); + $this->actingAs($this->f['receiver']); + app(SupplyHistory::class)->save($this->f['receiver'], supplyInput([], 'suspend', $old)); + $this->actingAs($this->f['issuer'])->get($query.'&history_period_from=2026-10-01&selected_history_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p->has('histories', 0)); +}); + +test('history source search validates scope dates and preparation rights', function () { + $old = approvedHistory($this); + $query = '/compras/fornecimento?view=calendar&history_stock_item_id='.$this->f['stock']->id.'&history_warehouse_id='.$this->f['warehouse']->id.'&history_purpose=internal'; + $this->actingAs($this->f['issuer'])->getJson($query.'&history_period_from=2026-10-30&history_period_until=2026-10-01')->assertUnprocessable(); + $this->getJson('/compras/fornecimento?view=calendar&history_period_from=2026-10-01')->assertUnprocessable(); + $tenant = Tenant::query()->create(['name' => 'Histórico isolado']); + $foreign = User::factory()->create(['tenant_id' => $tenant->id]); + supplyGrant($foreign, ['supply_prepare']); + $this->actingAs($foreign)->getJson($query.'&selected_history_key='.$old->record_key)->assertUnprocessable(); + $this->actingAs($this->f['receiver'])->get($query)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('histories', 0)); +}); + +test('supply pagination preserves an approved historical source already selected outside the recent choices', function () { + $history = approvedHistory($this); + $policy = historyPolicy($this, $history); + for ($i = 0; $i < 100; $i++) { + $copy = $history->replicate(); + $copy->record_key = (string) Str::uuid(); + $copy->operation_key = (string) Str::uuid(); + $copy->created_at = $history->created_at; + $copy->save(); + } + $before = [$policy->fresh()->getAttributes(), $history->fresh()->getAttributes(), DB::table('audit_logs')->count()]; + $this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar&record_id='.$policy->id)->assertInertia(fn (Assert $page) => $page + ->has('histories', 101)->where('histories', fn ($rows) => collect($rows)->where('key', $history->record_key)->count() === 1) + ->where('records.0.data.history_key', $history->record_key)); + expect([$policy->fresh()->getAttributes(), $history->fresh()->getAttributes(), DB::table('audit_logs')->count()])->toBe($before); + $this->actingAs($this->f['receiver']); + app(SupplyHistory::class)->save($this->f['receiver'], supplyInput([], 'suspend', $history)); + $this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar&record_id='.$policy->id)->assertInertia(fn (Assert $page) => $page + ->has('histories', 100)->where('histories', fn ($rows) => collect($rows)->where('key', $history->record_key)->isEmpty()) + ->where('records.0.data.history_key', $history->record_key)); + expect($policy->fresh()->getAttributes())->toBe($before[0])->and($history->fresh()->getAttributes())->toBe($before[1]); +}); + test('historical mean includes all calendar days and policy reuses the approved period without accepting forged totals', function () { $history = approvedHistory($this, ['total_milli' => 999999, 'daily_mean_milli' => '999']); expect($history->snapshot['analysis']['total_milli'])->toBe(300000)->and($history->snapshot['analysis']['days'])->toBe(30) @@ -185,3 +248,76 @@ test('replacement policy can reuse all approved parameters by reference while ac $this->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $copy))->assertRedirect(); expect(app(SupplyPlanning::class)->latest('policy')->where('state', 'approved')->count())->toBe(1)->and($source->fresh()->data['target_milli'])->toBe(900000); }); +test('history task routes the prepared analysis to an independent reviewer without changing scope or ledger', function () { + $draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f))); + supplyGrant($this->f['issuer'], ['supply_history_review']); + $path = '/compras/fornecimento?view=history&record_id='.$draft->id; + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.href', $path.'#supply-record-'.$draft->id)->where('tasks.data.0.due_on', null)->missing('tasks.data.0.snapshot')); + $before = [SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count()]; + $this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1) + ->where('focusRecordId', $draft->id)->where('focusChanged', false)->where('records.0.canApprove', true)->where('records.0.canReturn', true)); + expect([SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count()])->toBe($before); + $this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => false], 'approve', $draft))->assertUnprocessable(); + $approved = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput(['confirm_scope' => true], 'approve', $draft)); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id) + ->where('records.0.canApprove', false)->where('records.0.canSuspend', true)); +}); + +test('history task excludes every previous preparer and uses the specific historical competence', function () { + $draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f))); + supplyGrant($this->f['receiver'], ['supply_history_prepare']); + $this->actingAs($this->f['receiver']); + $updated = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput(historyData($this->f), 'save', $draft)); + supplyGrant($this->f['issuer'], ['supply_history_review']); + foreach ([$this->f['issuer'], $this->f['receiver']] as $user) { + $this->actingAs($user)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + expect(app(SupplyHistory::class)->canReview($user, $updated))->toBeFalse(); + } + supplyGrant($this->f['otherReceiver'], ['supply_review']); + $this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get('/compras/fornecimento?view=history')->assertForbidden(); + supplyGrant($this->f['otherReceiver'], ['supply_history_review']); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'history_review')); + expect(app(SupplyHistory::class)->canReview($this->f['otherReceiver'], $draft))->toBeFalse() + ->and(app(SupplyHistory::class)->canReview($this->f['otherReceiver'], $updated))->toBeTrue(); +}); + +test('history task keeps changed sources available for return and never approves stale analysis silently', function () { + $draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f))); + $this->f['stock']->update(['name' => 'Nome alterado depois da análise']); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); + $this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertUnprocessable(); + $returned = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput([], 'return', $draft)); + expect($returned->state)->toBe('returned')->and($returned->snapshot['item'])->toBe($draft->snapshot['item']); +}); + +test('history task preserves old pending analyses beyond the display cap and all task pages', function () { + $first = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f))); + for ($index = 1; $index < 105; $index++) { + app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f))); + } + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supply:'.$first->id.':review')); + $this->get('/requisicoes/tarefas?module=supply&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); + $this->get('/compras/fornecimento?view=history')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 105)); + $this->get('/compras/fornecimento?view=history&record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)); +}); + +test('history task preserves company isolation document type maintenance and grant expiry', function () { + $draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f))); + $path = '/compras/fornecimento?view=history&record_id='.$draft->id; + $tenant = Tenant::query()->create(['name' => 'Outra empresa de ensaio']); + $foreign = User::factory()->create(['tenant_id' => $tenant->id]); + supplyGrant($foreign, ['supply_history_review']); + $this->actingAs($foreign)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->getJson($path)->assertUnprocessable(); + $this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=calendar&record_id='.$draft->id)->assertUnprocessable(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($path)->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/requisicoes/tarefas?module=supply')->assertForbidden(); +}); diff --git a/tests/Feature/SupplyPaginationTest.php b/tests/Feature/SupplyPaginationTest.php new file mode 100644 index 0000000..9364712 --- /dev/null +++ b/tests/Feature/SupplyPaginationTest.php @@ -0,0 +1,166 @@ +f = suggestionFixture(); + supplyGrant($this->f['issuer'], ['supply_read', 'supply_history_prepare', 'import_prepare', 'import_read']); + $this->actingAs($this->f['issuer']); +}); + +test('agreement source search finds old approved sources and preserves selection without reviving suspension', function () { + $old = $this->f['agreement']->replicate(); + $old->record_key = (string) Str::uuid(); + $old->operation_key = (string) Str::uuid(); + $old->created_at = now(); + $old->data = [...$old->data, 'reference' => 'ACORDO-ANTIGO-PESQUISA']; + $old->save(); + for ($i = 0; $i < 100; $i++) { + $copy = $old->replicate(); + $copy->record_key = (string) Str::uuid(); + $copy->operation_key = (string) Str::uuid(); + $copy->data = [...$copy->data, 'reference' => 'RECENTE-'.$i]; + $copy->created_at = now(); + $copy->save(); + } + $base = '/compras/fornecimento?view=calendar&agreement_stock_item_id='.$this->f['stock']->id.'&agreement_warehouse_id='.$this->f['warehouse']->id; + $before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()]; + $this->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $p) => $p->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->isEmpty())); + $this->get($base.'&agreement_search=acordo-antigo')->assertInertia(fn (Assert $p) => $p + ->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->count() === 1)); + $this->get($base.'&agreement_search=NO-MATCH&selected_agreement_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p + ->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->count() === 1)); + expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()])->toBe($before); + $policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'agreement_key' => $old->record_key])); + expect($policy->data['agreement_version'])->toBe($old->version)->and($policy->data['lead_days'])->toBe($old->data['lead_days']); + $this->actingAs($this->f['receiver']); + app(SupplyPlanning::class)->save($this->f['receiver'], 'agreement', supplyInput([], 'suspend', $old)); + $this->actingAs($this->f['issuer'])->get($base.'&selected_agreement_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p + ->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->isEmpty())); + expect($old->fresh()->state)->toBe('approved'); +}); + +test('agreement source search validates company and scope and leaves readers without preparation choices', function () { + $base = '/compras/fornecimento?view=calendar'; + $this->getJson($base.'&agreement_search=TEST')->assertUnprocessable(); + $this->getJson($base.'&agreement_stock_item_id='.$this->f['stock']->id)->assertUnprocessable(); + $scope = '&agreement_stock_item_id='.$this->f['stock']->id.'&agreement_warehouse_id='.$this->f['warehouse']->id; + $this->getJson($base.$scope.'&agreement_search='.str_repeat('A', 101))->assertUnprocessable(); + $otherItem = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'OTHER-AGREEMENT', 'name' => 'Outro artigo do ensaio', 'unit' => 'cx']); + $this->get($base.'&agreement_stock_item_id='.$otherItem->id.'&agreement_warehouse_id='.$this->f['warehouse']->id.'&selected_agreement_key='.$this->f['agreement']->record_key) + ->assertInertia(fn (Assert $p) => $p->has('agreements', 0)); + $foreign = suggestionFixture(); + $this->actingAs($this->f['issuer'])->getJson($base.$scope.'&selected_agreement_key='.$foreign['agreement']->record_key)->assertUnprocessable(); + $this->get($base.$scope.'&agreement_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('agreements', 1)); + supplyGrant($this->f['otherReceiver'], ['supply_read']); + $this->actingAs($this->f['otherReceiver'])->get($base.$scope)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('agreements', 0)); +}); + +test('supply pagination retains old records beyond one hundred and preserves direct focus without writes', function (string $view) { + $first = match ($view) { + 'agreements' => $this->f['agreement'], + 'calendar' => $this->f['policy'], + 'suggestions' => app(SupplySuggestions::class)->save($this->f['issuer'], supplyInput(['policy_key' => $this->f['policy']->record_key])), + 'history' => app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, + 'warehouse_id' => $this->f['warehouse']->id, 'purpose' => 'internal', 'period_from' => today()->subDays(30)->toDateString(), + 'period_until' => today()->subDay()->toDateString(), 'quality' => 'complete', 'pattern' => 'regular'])), + }; + for ($i = 0; $i < 104; $i++) { + $copy = $first->replicate(); + $copy->record_key = (string) Str::uuid(); + $copy->operation_key = (string) Str::uuid(); + $copy->state = 'draft'; + $copy->version = 1; + $copy->created_at = $first->created_at; + $copy->save(); + } + $before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count(), $first->fresh()->getAttributes()]; + $this->get('/compras/fornecimento?view='.$view)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 105)->has('records', 25)); + $this->get('/compras/fornecimento?view='.$view.'&records_page=5')->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page + ->where('recordPagination.current_page', 5)->has('records', 5)->where('records.4.id', $first->id) + ->where('recordPagination.prev_page_url', fn ($url) => str_contains($url, 'view='.$view))); + $this->get('/compras/fornecimento?view='.$view.'&record_id='.$first->id.'&records_page=5')->assertInertia(fn (Assert $page) => $page->where('recordPagination', null) + ->has('records', 1)->where('records.0.id', $first->id)); + expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count(), $first->fresh()->getAttributes()])->toBe($before); +})->with(['agreements', 'calendar', 'suggestions', 'history']); + +test('supply pagination keeps import milestones in one current record and preserves its historical source', function () { + $this->f = receiptFixture(); + supplyGrant($this->f['issuer'], ['procurement', 'import_prepare']); + $this->actingAs($this->f['issuer']); + $service = app(SupplyPlanning::class); + $first = $service->save($this->f['issuer'], 'import', supplyInput(['purchase_order_id' => $this->f['order']->id, 'purpose' => 'internal', + 'jurisdiction' => 'Jurisdição sintética', 'origin_country' => 'País A', 'dispatch_country' => 'País B', 'regime' => 'Regime fictício de teste'])); + $before = $first->fresh()->getAttributes(); + $current = $service->save($this->f['issuer'], 'import', supplyInput(['evidence' => 'Documento fictício de trânsito', 'event_date' => today()->toDateString()], 'transit', $first)); + $this->get('/compras/fornecimento?view=imports')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 1)->has('records', 1)->where('records.0.id', $current->id)); + $this->get('/compras/fornecimento?view=imports&record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->where('recordPagination', null) + ->where('focusChanged', true)->where('records.0.id', $current->id)->has('history.'.$first->record_key, 2)); + expect($first->fresh()->getAttributes())->toBe($before); +}); + +test('supply pagination enforces company scope and page validation across the views', function (string $view) { + $this->getJson('/compras/fornecimento?view='.$view.'&records_page=0')->assertUnprocessable(); + $this->getJson('/compras/fornecimento?view='.$view.'&records_page=1000001')->assertUnprocessable(); + $tenant = Tenant::query()->create(['name' => 'Empresa sintética isolada']); + $foreign = User::factory()->create(['tenant_id' => $tenant->id]); + supplyGrant($foreign, ['supply_read', 'import_read']); + $this->actingAs($foreign)->get('/compras/fornecimento?view='.$view)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0)->has('records', 0)); + $this->getJson('/compras/fornecimento?view='.$view.'&record_id='.$this->f['agreement']->id)->assertUnprocessable(); +})->with(['agreements', 'calendar', 'suggestions', 'history', 'imports']); + +test('supply pagination preserves maintenance and membership expiry boundaries', function () { + DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->get('/compras/fornecimento?view=agreements')->assertForbidden(); + DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/compras/fornecimento?view=calendar')->assertForbidden(); +}); + +test('supply pagination preserves the approved policy of an older suggestion outside recent choices', function () { + $suggestion = app(SupplySuggestions::class)->save($this->f['issuer'], supplyInput(['policy_key' => $this->f['policy']->record_key])); + for ($i = 0; $i < 100; $i++) { + $item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, + 'code' => 'POLICY-CHOICE-'.$i, 'name' => 'Artigo sintético de política '.$i, 'unit' => 'cx']); + $this->actingAs($this->f['issuer']); + $draft = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'stock_item_id' => $item->id])); + $this->actingAs($this->f['receiver']); + app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft)); + } + $before = [$suggestion->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()]; + $this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions&record_id='.$suggestion->id)->assertInertia(fn (Assert $page) => $page + ->has('policies', 101)->where('policies', fn ($rows) => collect($rows)->where('key', $this->f['policy']->record_key)->count() === 1) + ->where('records.0.data.policy_key', $this->f['policy']->record_key)); + expect([$suggestion->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()])->toBe($before); + $this->actingAs($this->f['receiver']); + app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy'])); + $this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions&record_id='.$suggestion->id)->assertInertia(fn (Assert $page) => $page + ->has('policies', 100)->where('policies', fn ($rows) => collect($rows)->where('key', $this->f['policy']->record_key)->isEmpty()) + ->where('records.0.data.policy_key', $this->f['policy']->record_key)); + expect($suggestion->fresh()->getAttributes())->toBe($before[0]); +}); + +test('supply pagination import source search preserves selection validates scope and never creates documents', function () { + $before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()]; + $number = mb_strtolower($this->f['order']->number); + $this->get('/compras/fornecimento?view=imports&order_search='.urlencode($number))->assertInertia(fn (Assert $page) => $page->has('orders', 1)->where('orders.0.id', $this->f['order']->id)); + $this->get('/compras/fornecimento?view=imports&order_search=NO-MATCH-FICTICIO')->assertInertia(fn (Assert $page) => $page->has('orders', 0)); + $this->get('/compras/fornecimento?view=imports&order_search=NO-MATCH-FICTICIO&selected_order_id='.$this->f['order']->id)->assertInertia(fn (Assert $page) => $page->has('orders', 1)->where('orders.0.id', $this->f['order']->id)); + $this->getJson('/compras/fornecimento?view=imports&order_search='.str_repeat('A', 101))->assertUnprocessable(); + $this->get('/compras/fornecimento?view=imports&order_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $page) => $page->has('orders', 0)); + expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()])->toBe($before); + $foreign = receiptFixture(); + $this->actingAs($this->f['issuer'])->getJson('/compras/fornecimento?view=imports&selected_order_id='.$foreign['order']->id)->assertUnprocessable(); + supplyGrant($this->f['otherReceiver'], ['import_read']); + $this->actingAs($this->f['otherReceiver'])->get('/compras/fornecimento?view=imports&selected_order_id='.$this->f['order']->id)->assertInertia(fn (Assert $page) => $page->where('canPrepare', false)->has('orders', 0)); +}); diff --git a/tests/Feature/SupplySuggestionsTest.php b/tests/Feature/SupplySuggestionsTest.php index a46d790..371488f 100644 --- a/tests/Feature/SupplySuggestionsTest.php +++ b/tests/Feature/SupplySuggestionsTest.php @@ -1,6 +1,7 @@ latest('suggestion')->sole(); } +test('policy source search finds an older approved policy and keeps only eligible selections', function () { + for ($i = 0; $i < 100; $i++) { + $item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, + 'code' => 'LOOKUP-'.$i, 'name' => 'Outro artigo de teste '.$i, 'unit' => 'cx']); + $this->actingAs($this->f['issuer']); + $draft = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'stock_item_id' => $item->id])); + $this->actingAs($this->f['receiver']); + app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft)); + } + $before = [$this->f['policy']->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()]; + $this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions')->assertInertia(fn (Assert $p) => $p->has('policies', 100) + ->where('policies', fn ($rows) => collect($rows)->where('key', $this->f['policy']->record_key)->isEmpty())); + $this->get('/compras/fornecimento?view=suggestions&policy_search='.urlencode(mb_strtolower($this->f['stock']->code)))->assertInertia(fn (Assert $p) => $p->has('policies', 1)->where('policies.0.key', $this->f['policy']->record_key)); + $this->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO')->assertInertia(fn (Assert $p) => $p->has('policies', 0)); + $this->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->has('policies', 1)); + expect([$this->f['policy']->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($before); + $this->actingAs($this->f['receiver']); + app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy'])); + $this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->has('policies', 0)); + expect($this->f['policy']->fresh()->getAttributes())->toBe($before[0]); +}); + +test('policy source search validates inputs company scope and preparation rights', function () { + $this->getJson('/compras/fornecimento?view=suggestions&policy_search='.str_repeat('A', 101))->assertUnprocessable(); + $this->get('/compras/fornecimento?view=suggestions&policy_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('policies', 0)); + $this->actingAs($this->f['receiver'])->get('/compras/fornecimento?view=suggestions&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('policies', 0)); + $foreign = suggestionFixture(); + $this->actingAs($foreign['issuer'])->getJson('/compras/fornecimento?view=suggestions&selected_policy_key='.$this->f['policy']->record_key)->assertUnprocessable(); +}); + test('suggestion freezes the forecast catalogue agreement and company defaults without repeating descriptors', function () { $input = supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => null, 'name' => 'FORGED', 'snapshot' => ['balance' => 999]]); $this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect(); @@ -136,3 +167,69 @@ test('another company cannot resolve a suggestion or convert with another depart $approved = approveSuggestion($this, $draft); $this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $other['r']->cost_center_id], 'convert', $approved))->assertUnprocessable(); }); +test('suggestion task connects independent review conversion and completed tracking without duplicate drafts', function () { + $draft = saveSuggestion($this); + $path = '/compras/fornecimento?view=suggestions&record_id='.$draft->id; + supplyGrant($this->f['issuer'], ['supply_suggest_review']); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.task', 'suggestion_approve')->where('tasks.data.0.href', $path.'#supply-suggestion-'.$draft->id)->missing('tasks.data.0.snapshot')); + $before = [SupplyRecord::query()->count(), Requisition::query()->count(), DB::table('audit_logs')->count()]; + $this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canApprove', true)); + expect([SupplyRecord::query()->count(), Requisition::query()->count(), DB::table('audit_logs')->count()])->toBe($before); + $approved = approveSuggestion($this, $draft); + supplyGrant($this->f['receiver'], ['supply_suggest_convert', 'requester']); + expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $approved, 'convert'))->toBeFalse(); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'suggestion_convert')); + $this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.canConvert', true)); + $command = supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved); + $this->postJson('/compras/fornecimento/suggestion', $command)->assertRedirect(); + $this->postJson('/compras/fornecimento/suggestion', $command)->assertRedirect(); + expect(Requisition::query()->count())->toBe($before[1] + 1); + $converted = app(SupplyPlanning::class)->latest('suggestion')->sole(); + $purchase = Requisition::query()->findOrFail($converted->data['requisition_id']); + expect($purchase->status)->toBe('draft')->and($purchase->steps()->count())->toBe(0); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $purchase->update(['status' => 'rejected']); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'suggestion_close')); + $this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'close', $converted))->assertRedirect(); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); +}); + +test('suggestion task preserves source revalidation specific grants and focus isolation', function () { + $draft = saveSuggestion($this); + $this->f['stock']->update(['name' => 'Origem alterada depois da preparação']); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); + $this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable(); + $this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'return', $draft))->assertRedirect(); + $returned = app(SupplyPlanning::class)->latest('suggestion')->sole(); + expect($returned->snapshot['item'])->toBe($draft->snapshot['item'])->and(app(SupplySuggestions::class)->canAct($this->f['receiver'], $draft, 'approve'))->toBeFalse(); + $this->getJson('/compras/fornecimento?view=calendar&record_id='.$returned->id)->assertUnprocessable(); + $foreign = suggestionFixture(); + $this->actingAs($foreign['receiver'])->getJson('/compras/fornecimento?view=suggestions&record_id='.$returned->id)->assertUnprocessable(); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->actingAs($this->f['receiver']); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $returned, 'approve'))->toBeFalse(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/requisicoes/tarefas?module=supply')->assertForbidden(); +}); + +test('suggestion task excludes all preparers and conversion without requester authority', function () { + $draft = saveSuggestion($this); + supplyGrant($this->f['receiver'], ['supply_suggest_prepare']); + $this->actingAs($this->f['receiver']); + $updated = app(SupplySuggestions::class)->save($this->f['receiver'], supplyInput(['policy_key' => $this->f['policy']->record_key], 'save', $draft)); + supplyGrant($this->f['issuer'], ['supply_suggest_review']); + foreach ([$this->f['issuer'], $this->f['receiver']] as $user) { + expect(app(SupplySuggestions::class)->canAct($user, $updated, 'approve'))->toBeFalse(); + } + supplyGrant($this->f['otherReceiver'], ['supply_suggest_review']); + $this->actingAs($this->f['otherReceiver']); + $approved = app(SupplySuggestions::class)->save($this->f['otherReceiver'], supplyInput([], 'approve', $updated)); + DB::table('organization_profiles')->whereIn('id', DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->pluck('profile_id'))->update(['permissions' => json_encode(['supply_suggest_convert'])]); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $approved, 'convert'))->toBeFalse(); +}); diff --git a/tests/Feature/SupplyTaskIntegrationTest.php b/tests/Feature/SupplyTaskIntegrationTest.php new file mode 100644 index 0000000..aa4837d --- /dev/null +++ b/tests/Feature/SupplyTaskIntegrationTest.php @@ -0,0 +1,86 @@ +f = suggestionFixture(); + $this->actingAs($this->f['receiver']); + app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy'])); + $this->data = [...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]; + $this->actingAs($this->f['issuer']); + $this->draft = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput($this->data)); + $this->path = '/compras/fornecimento?view=calendar&record_id='.$this->draft->id; +}); + +test('supply task policy reaches an independent reviewer and a stale link shows the new state without approving twice', function () { + supplyGrant($this->f['issuer'], ['supply_review']); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false)->where('records.0.canReturn', false)); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) + ->where('tasks.data.0.href', $this->path.'#supply-record-'.$this->draft->id)->missing('tasks.data.0.data')->missing('tasks.data.0.snapshot')); + $before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()]; + $this->get($this->path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1) + ->where('focusRecordId', $this->draft->id)->where('focusChanged', false)->where('records.0.canApprove', true)->where('records.0.canSuspend', false)); + expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()])->toBe($before); + $approved = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $this->draft)); + $this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id) + ->where('records.0.canApprove', false)->where('records.0.canSuspend', true)); +}); + +test('supply task excludes every preparation contributor even after another person edits the current version', function () { + supplyGrant($this->f['issuer'], ['supply_review']); + supplyGrant($this->f['receiver'], ['supply_prepare']); + $this->actingAs($this->f['receiver']); + $updated = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([...$this->data, 'basis' => 'Revisão sintética conservando os intervenientes.'], 'save', $this->draft)); + supplyGrant($this->f['otherReceiver'], ['supply_review']); + foreach ([$this->f['issuer'], $this->f['receiver']] as $contributor) { + $this->actingAs($contributor)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + expect(app(SupplyPlanning::class)->canReview($contributor, $updated))->toBeFalse(); + } + $this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.id', 'supply:'.$updated->id.':review')); +}); + +test('supply task keeps a changed source available for return but cannot silently approve it', function () { + $this->f['stock']->update(['name' => 'Cadastro alterado depois da preparação']); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); + $this->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $this->draft))->assertUnprocessable(); + $returned = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'return', $this->draft)); + expect($returned->state)->toBe('returned')->and($returned->snapshot['item'])->toBe($this->draft->snapshot['item']); +}); + +test('supply task retains older preparations across the former hundred row display limit', function () { + $first = $this->draft; + for ($index = 1; $index < 105; $index++) { + app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput($this->data)); + } + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supply:'.$first->id.':review')); + $this->get('/requisicoes/tarefas?module=supply&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); + $this->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 106)); + $this->get($this->path)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)->where('records.0.canApprove', true)); +}); + +test('supply task and record focus preserve company isolation and the kind of the document', function () { + $tenant = Tenant::query()->create(['name' => 'Outra empresa sintética']); + $foreign = User::factory()->create(['tenant_id' => $tenant->id]); + supplyGrant($foreign, ['supply_review']); + $this->actingAs($foreign)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->getJson($this->path)->assertUnprocessable(); + $this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=agreements&record_id='.$this->draft->id)->assertUnprocessable(); +}); + +test('supply task does not inherit execution from maintenance and expires with the saved grant', function () { + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); + $this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); + $this->get($this->path)->assertForbidden(); + expect(app(SupplyPlanning::class)->canReview($this->f['receiver'], $this->draft))->toBeFalse(); + DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]); + $this->get('/requisicoes/tarefas?module=supply')->assertForbidden(); +}); diff --git a/tests/Feature/WarehousePilotReadinessTest.php b/tests/Feature/WarehousePilotReadinessTest.php index bd8ef2f..58e30d3 100644 --- a/tests/Feature/WarehousePilotReadinessTest.php +++ b/tests/Feature/WarehousePilotReadinessTest.php @@ -2,10 +2,14 @@ use App\Models\Requisition; use App\Services\AutomaticPurchases; +use App\Services\ReplenishmentHeartbeat; use App\Services\ReplenishmentQueue; use App\Services\SupplyPlanning; use App\Services\WarehousePilotReadiness; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia as Assert; @@ -19,6 +23,15 @@ beforeEach(function () { $this->actingAs($this->f['receiver']); $this->f['policy'] = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy)); supplyGrant($this->f['otherReceiver'], ['admin']); + $this->readinessOriginalStorage = app()->storagePath(); + $this->readinessStorage = $this->readinessOriginalStorage.'/framework/testing/readiness-'.Str::uuid(); + File::ensureDirectoryExists($this->readinessStorage.'/framework'); + app()->useStoragePath($this->readinessStorage); +}); + +afterEach(function () { + app()->useStoragePath($this->readinessOriginalStorage); + File::deleteDirectory($this->readinessStorage); }); function pilotReadinessRule($test): void @@ -118,6 +131,31 @@ test('optional scheduler telemetry is isolated and a stale or damaged file does } }); +test('native scheduler observation is available outside evaluation and takes precedence over helper telemetry', function () { + $originalStorage = app()->storagePath(); + $originalEnv = app()->environment(); + $temporary = $originalStorage.'/framework/testing/native-status-'.Str::uuid(); + mkdir($temporary.'/framework', 0755, true); + app()->useStoragePath($temporary); + app()->instance('env', 'production'); + $this->actingAs($this->f['otherReceiver']); + try { + app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0); + $status = app(ReplenishmentQueue::class)->status($this->f['otherReceiver']); + expect($status['schedulerRecent'])->toBeTrue()->and($status['schedulerStatus']['result'])->toBe('passed'); + app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 1); + file_put_contents($temporary.'/framework/replenishment-status.json', json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR)); + app()->instance('env', 'payable-testing'); + expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerStatus']['result'])->toBe('retry_pending'); + file_put_contents($temporary.'/framework/replenishment-command-status.json', '{broken'); + expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerStatus'])->toBeNull(); + } finally { + app()->useStoragePath($originalStorage); + app()->instance('env', $originalEnv); + File::deleteDirectory($temporary); + } +}); + test('a recent successful scheduler does not hide durable work still awaiting recovery', function () { pilotReadinessRule($this); $this->actingAs($this->f['otherReceiver']); @@ -143,3 +181,21 @@ test('a recent successful scheduler does not hide durable work still awaiting re rmdir($temporary); } }); +test('command readiness does not certify a pilot missing receipt storage or the requisition version', function (string $missing) { + supplyGrant($this->f['issuer'], ['warehouse_request_prepare']); + supplyGrant($this->f['receiver'], ['warehouse_request_review']); + supplyGrant($this->f['otherReceiver'], ['warehouse', 'warehouse_request_deliver']); + pilotReadinessRule($this); + $this->actingAs($this->f['otherReceiver']); + expect(app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver'])['warehouse_scenario_ready'])->toBeTrue(); + $counts = [DB::table('requisitions')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()]; + if ($missing === 'receipts') { + Schema::drop('requisition_commands'); + } else { + Schema::table('requisitions', fn (Blueprint $table) => $table->dropColumn('process_version')); + } + $report = app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver']); + expect($report['checks'][0]['ok'])->toBeFalse()->and($report['warehouse_scenario_ready'])->toBeFalse()->and($report['replenishment_scenario_ready'])->toBeFalse() + ->and($report['tables'][$missing === 'receipts' ? 'requisition_commands' : 'requisition_process_version'])->toBeFalse() + ->and([DB::table('requisitions')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($counts); +})->with(['receipts', 'version']); diff --git a/tests/Feature/WorkflowTaskInboxTest.php b/tests/Feature/WorkflowTaskInboxTest.php index 4d052d9..a9ceeea 100644 --- a/tests/Feature/WorkflowTaskInboxTest.php +++ b/tests/Feature/WorkflowTaskInboxTest.php @@ -8,6 +8,7 @@ use App\Services\RequisitionCatalogue; use App\Services\RequisitionWorkflow; use App\Services\WarehouseFulfilment; use App\Services\WarehouseRequests; +use App\Services\WorkflowTaskInbox; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia as Assert; @@ -38,6 +39,32 @@ function taskInboxRequest($test, string $quantity = '4.000'): void taskInboxCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]); } +test('task deadline filtering uses the whole authorised projection before pagination and preserves module search scope', function () { + $this->travelTo(now()->startOfDay()->addHours(23)->addMinutes(59)); + $rows = []; + for ($i = 0; $i < 28; $i++) { + $rows[] = ['id' => 'deadline:'.$i, 'module' => 'supply', 'reference' => 'PRAZO-'.$i, 'label' => 'Acompanhar origem existente', 'blocked' => false, + 'created_at' => now()->subMonth()->toIso8601String(), 'due_on' => today()->subDay()->toDateString(), 'href' => '/compras/fornecimento?view=reservations']; + } + foreach (['today' => today()->toDateString(), 'upcoming' => today()->addDay()->toDateString(), 'undated' => null] as $status => $date) { + $rows[] = [...$rows[0], 'id' => 'deadline:'.$status, 'reference' => 'PRAZO-'.$status, 'due_on' => $date]; + } + $rows[] = [...$rows[0], 'id' => 'other', 'module' => 'warehouse', 'reference' => 'OUTRO']; + $this->mock(WorkflowTaskInbox::class)->shouldReceive('build')->andReturn($rows); + $this->get('/requisicoes/tarefas?module=supply&due=overdue')->assertInertia(fn (Assert $p) => $p + ->where('tasks.total', 28)->has('tasks.data', 25)->where('tasks.data.0.due_status', 'overdue') + ->where('deadlineCounts.overdue', 28)->where('deadlineCounts.today', 1)->where('deadlineCounts.upcoming', 1)->where('deadlineCounts.undated', 1) + ->where('counts.warehouse', 1)->where('asOf', today()->toDateString())->where('filters.due', 'overdue') + ->where('tasks.next_page_url', fn ($url) => str_contains($url, 'due=overdue') && str_contains($url, 'module=supply'))); + $this->get('/requisicoes/tarefas?module=supply&due=overdue&page=2')->assertInertia(fn (Assert $p) => $p->has('tasks.data', 3)->where('tasks.data.0.id', 'deadline:25')); + foreach (['today', 'upcoming', 'undated'] as $status) { + $this->get('/requisicoes/tarefas?module=supply&due='.$status)->assertInertia(fn (Assert $p) => $p->where('tasks.total', 1)->where('tasks.data.0.due_status', $status)); + } + $this->get('/requisicoes/tarefas?module=supply&search=PRAZO-today&due=overdue')->assertInertia(fn (Assert $p) => $p + ->where('tasks.total', 0)->where('deadlineCounts.today', 1)->where('deadlineCounts.overdue', 0)); + $this->getJson('/requisicoes/tarefas?due=invalid')->assertUnprocessable(); +}); + function taskInboxCommand($test, User $actor, string $action, array $extra = []) { $test->actingAs($actor); diff --git a/tests/Support/FortifyMfa.php b/tests/Support/FortifyMfa.php new file mode 100644 index 0000000..9fb0b10 --- /dev/null +++ b/tests/Support/FortifyMfa.php @@ -0,0 +1,12 @@ + [Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true])]]); + // Native route definitions only in the disposable test application. + require base_path('vendor/laravel/fortify/routes/routes.php'); + app('router')->getRoutes()->refreshNameLookups(); + app('router')->getRoutes()->refreshActionLookups(); +} diff --git a/tests/Support/native-payable-page-worker.php b/tests/Support/native-payable-page-worker.php new file mode 100644 index 0000000..385b869 --- /dev/null +++ b/tests/Support/native-payable-page-worker.php @@ -0,0 +1,10 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$p=json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster"); +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 LogicException('QA worker database refused'); +config(['common-payables.enabled'=>true,'common-payables.history_enabled'=>true,'common-payables.paged_read_enabled'=>true,'common-payables.source_instance'=>$p['instance']]);$u=App\Models\User::query()->findOrFail($p['actor']);auth()->setUser($u);$time=hrtime(true); +try{$d=app(App\Services\SupplierPayables::class)->listing($u);$out=['status'=>200,'documents'=>count($d['documents']),'balances'=>array_column($d['obligations'],'balance_minor'),'time_ms'=>(hrtime(true)-$time)/1e6];} +catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$out=['status'=>$e->getStatusCode()];} +file_put_contents($file.'.result',json_encode($out,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));