diff --git a/app/Console/Commands/SgcDeliveryCheck.php b/app/Console/Commands/SgcDeliveryCheck.php new file mode 100644 index 0000000..5bcebf9 --- /dev/null +++ b/app/Console/Commands/SgcDeliveryCheck.php @@ -0,0 +1,75 @@ +find($this->argument('tenant')); + if ($tenant === null || ! Schema::hasColumn('tenants', 'work_context_policy')) { + $this->error('Empresa inexistente ou instalação do contexto pendente.'); + + return self::FAILURE; + } + $users = User::query()->where('tenant_id', $tenant->id)->get(); + $actor = $users->first(); + if ($actor === null || $contexts->policy($actor) === null || $contexts->purchased($actor) === [] || ! app(OrganizationAccess::class)->active($tenant->id)) { + $this->error('Defina produto contratado, opções e administração da empresa antes da entrega.'); + + return self::FAILURE; + } + $administrators = 0; + foreach ($users as $user) { + $profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id') + ->where('m.tenant_id', $tenant->id)->where('p.tenant_id', $tenant->id)->where('m.user_id', $user->id) + ->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions'); + if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) { + $administrators++; + } + } + $clean = true; + foreach (Schema::getTables() as $table) { + $name = $table['name']; + if (in_array($name, ['users', 'departments', 'cost_centers', 'organization_profiles', 'organization_memberships', 'organization_access', 'audit_logs'], true)) { + continue; + } + if (Schema::hasColumn($name, 'tenant_id') && DB::table($name)->where('tenant_id', $tenant->id)->exists()) { + $this->error('Existem registos a reconciliar antes da entrega: '.$name); + $clean = false; + } + } + $enabled = $contexts->policy($actor)['enabled'] ?? []; + if ($administrators < 2 || $enabled === [] || array_diff($enabled, $contexts->purchased($actor)) !== []) { + $this->error('Confira dois administradores permanentes e opções activadas dentro do produto contratado.'); + $clean = false; + } + foreach ($enabled as $key) { + if (! (WorkContext::OPTIONS[$key]['operational'] ?? false)) { + $this->error('Percurso operacional ainda pendente: '.$key); + $clean = false; + } + if (! $users->contains(fn ($user) => in_array($key, $contexts->available($user), true))) { + $this->error('Opção sem utilizador com perfil/contexto autorizado: '.$key); + $clean = false; + } + } + if ($clean) { + $this->info('Conferência técnica aprovada. Não substitui aceitação funcional, revisão dos cadastros de configuração ou instalação de RH.'); + } + + return $clean ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Http/Controllers/AutomaticPurchaseController.php b/app/Http/Controllers/AutomaticPurchaseController.php new file mode 100644 index 0000000..abea932 --- /dev/null +++ b/app/Http/Controllers/AutomaticPurchaseController.php @@ -0,0 +1,50 @@ +user('web'); + abort_unless($actor instanceof User && $actor->hasFunctions(['admin','supply_read','supply_prepare','supply_review']),403); + $prepare=app(SupplyPlanning::class)->allows($actor,'supply_prepare'); + $ready=$service->ready(); + $planning=app(SupplyPlanning::class); + Inertia::encryptHistory(); + return Inertia::render('stock/replenishment',[ + 'ready'=>$ready,'canPrepare'=>$prepare,'canReview'=>$planning->allows($actor,'supply_review'), + 'rules'=>$ready?$planning->latest('auto_rule')->latest('id')->limit(100)->get():[], + 'needs'=>$ready?$planning->latest('auto_need')->latest('id')->limit(100)->get():[], + 'history'=>$ready?SupplyRecord::query()->whereIn('kind',['auto_rule','auto_need'])->latest('id')->limit(100)->get():[], + 'policies'=>Schema::hasTable('supply_records') && $prepare?$planning->latest('policy')->where('state','approved')->where('data->purpose','internal')->get():[], + 'requesters'=>$prepare?User::query()->where('tenant_id',$actor->tenant_id)->get(['id','name','department_id','purchasing_role','tenant_id'])->filter(fn($u)=>in_array('requester',app(OrganizationAccess::class)->permissions($u,'procurement.internal'),true))->map(fn($u)=>$u->only(['id','name','department_id']))->values():[], + 'centers'=>$prepare?CostCenter::query()->orderBy('code')->get(['id','department_id','code','name']):[], + ])->toResponse($request)->withHeaders(['Cache-Control'=>'private, no-store, max-age=0']); + } + + public function save(Request $request, AutomaticPurchases $service) + { + $service->save($request->user('web'),$request->all()); + return redirect('/stock/reposicao'); + } + + public function evaluate(Request $request, int $id, AutomaticPurchases $service) + { + $actor=$request->user('web'); + abort_unless($actor instanceof User && ($actor->hasFunctions(['supply_prepare','supply_review'])) && !app(OrganizationAccess::class)->maintenanceOnly($actor),403); + $need=SupplyRecord::query()->where('kind','auto_need')->findOrFail($id); + $service->evaluate($actor,$need->stock_item_id,$need->warehouse_id); + return redirect('/stock/reposicao'); + } +} diff --git a/app/Http/Controllers/GoodsReceiptController.php b/app/Http/Controllers/GoodsReceiptController.php index b7bd9a3..3d5f9cd 100644 --- a/app/Http/Controllers/GoodsReceiptController.php +++ b/app/Http/Controllers/GoodsReceiptController.php @@ -9,12 +9,15 @@ use App\Models\Requisition; use App\Models\StockItem; use App\Models\StockMovement; use App\Models\Warehouse; +use App\Services\InventoryLots; +use App\Services\InventoryReversals; use App\Services\ReceiptWorkflow; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Schema; use Inertia\Inertia; use Inertia\Response; @@ -55,6 +58,8 @@ class GoodsReceiptController extends Controller 'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']), 'stockItems' => StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']), 'canConfirm' => $request->user()->can('create', [GoodsReceipt::class, $order]), + 'lotScopes' => Schema::hasTable('inventory_lot_allocations') + ? app(InventoryLots::class)->activeSetups()->get()->map(fn ($r) => ['stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'quarantine_on_receipt' => $r->data['quarantine_on_receipt'] ?? true])->values()->all() : [], ]); } @@ -73,6 +78,7 @@ class GoodsReceiptController extends Controller Gate::authorize('view', $receipt); return Inertia::render('receipts/show', [ + '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(), ]); diff --git a/app/Http/Controllers/InventoryDispositionController.php b/app/Http/Controllers/InventoryDispositionController.php new file mode 100644 index 0000000..842466d --- /dev/null +++ b/app/Http/Controllers/InventoryDispositionController.php @@ -0,0 +1,75 @@ +user('web'); + abort_unless($actor instanceof User, 403); + $capabilities = []; + foreach (['read', 'prepare', 'review', 'execute'] as $capability) { + $capabilities[$capability] = $workflow->allows($actor, $capability); + } + $canViewReversals = collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => app(InventoryReversals::class)->allows($actor, $a)); + if (! collect($capabilities)->contains(fn (bool $allow) => $allow) && $canViewReversals) { + 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(); + $receipts = []; + $lots = []; + if ($ready && $capabilities['prepare']) { + foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() 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']]; + } + foreach (app(InventoryLots::class)->activeSetups()->get() as $setup) { + $context = app(InventoryLots::class)->context($setup->stock_item_id, $setup->warehouse_id); + foreach ($context['lots'] ?? [] as $lot) { + if ($lot['quantity_milli'] > 0) { + $lots[] = ['id' => $lot['id'], 'stock_item_id' => $setup->stock_item_id, 'warehouse_id' => $setup->warehouse_id, 'code' => $lot['data']['code'], 'quantity_milli' => $lot['quantity_milli'], + 'receipt_item_id' => $lot['origin']['receipt_item_id'] ?? null, 'reconciled' => $context['reconciled']]; + } + } + } + } + + return Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, 'records' => $records, + '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') : []]); + } + + public function save(Request $request, InventoryDispositions $workflow): RedirectResponse + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário de stock pendente.'); + $workflow->save($actor, $request->all()); + + return back()->with('success', 'Processo de stock registado.'); + } +} diff --git a/app/Http/Controllers/InventoryLotController.php b/app/Http/Controllers/InventoryLotController.php new file mode 100644 index 0000000..d7f57dc --- /dev/null +++ b/app/Http/Controllers/InventoryLotController.php @@ -0,0 +1,53 @@ +user('web'); + abort_unless($actor instanceof User, 403); + $capabilities = []; + foreach (['read', 'prepare', 'review', 'hold', 'release'] as $capability) { + $capabilities[$capability] = $lots->allows($actor, 'inventory_lot_'.$capability); + } + abort_unless(collect($capabilities)->contains(fn (bool $allowed) => $allowed), 403); + $ready = Schema::hasTable('inventory_lot_allocations'); + $records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->limit(100)->get() : collect(); + if ($ready) { + $records = $records->concat($lots->activeSetups()->latest('id')->limit(100)->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); + } + + return Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities, 'records' => $records, '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() : []]); + } + + public function save(Request $request, InventoryLots $lots): RedirectResponse + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + abort_unless(Schema::hasTable('inventory_lot_allocations'), 503, 'Instalação do controlo por lotes pendente.'); + $lots->save($actor, $request->all()); + + return back()->with('success', 'Conferência de lotes registada.'); + } +} diff --git a/app/Http/Controllers/InventoryReversalController.php b/app/Http/Controllers/InventoryReversalController.php new file mode 100644 index 0000000..1de7706 --- /dev/null +++ b/app/Http/Controllers/InventoryReversalController.php @@ -0,0 +1,64 @@ +user('web'); + abort_unless($actor instanceof User, 403); + $capabilities = []; + foreach (['read', 'prepare', 'review', 'execute'] as $capability) { + $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(); + $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; + } + $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, + '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') : []]); + } + + public function save(Request $request, InventoryReversals $workflow): RedirectResponse + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário pendente.'); + $workflow->save($actor, $request->all()); + + return back()->with('success', 'Estorno registado com a origem preservada.'); + } +} diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 7bc64ee..29bff35 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers; use App\Enums\PurchasingRole; use App\Models\Invoice; use App\Models\PurchaseOrder; +use App\Services\InventoryDispositions; +use App\Services\InventoryReversals; use App\Services\InvoiceWorkflow; use App\Services\SupplierPayables; use Illuminate\Http\RedirectResponse; @@ -18,7 +20,7 @@ class InvoiceController extends Controller { abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403); - return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]); + return Inertia::render('invoices/index', ['stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(), 'invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]); } public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse diff --git a/app/Http/Controllers/OrganizationConfigurationController.php b/app/Http/Controllers/OrganizationConfigurationController.php index 13958e5..70d6147 100644 --- a/app/Http/Controllers/OrganizationConfigurationController.php +++ b/app/Http/Controllers/OrganizationConfigurationController.php @@ -8,6 +8,8 @@ use App\Services\ComparisonPolicy; use App\Services\IntelligencePolicy; use App\Services\OrganizationAccess; use App\Services\OrganizationConfiguration; +use App\Services\WorkContext; +use App\Services\WorkflowTasks; use Brick\Math\BigDecimal; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -25,6 +27,7 @@ class OrganizationConfigurationController extends Controller $state = $ready ? DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->first() : null; Inertia::encryptHistory(); $response = Inertia::render('company/parameters', ['ready' => $ready, 'active' => (bool) ($state->active ?? false), 'version' => $state->version ?? 1, + 'workflowTasks' => app(WorkflowTasks::class)->catalogue(), 'companyRules' => $this->companyRules((int) $actor->tenant_id), 'comparisonPolicy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)), 'intelligencePolicy' => app(IntelligencePolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)), @@ -32,7 +35,9 @@ class OrganizationConfigurationController extends Controller 'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)), 'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES, 'permissions' => OrganizationAccess::PERMISSIONS, - 'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR)]) : [], + 'workContextConfiguration' => Schema::hasColumn('tenants', 'work_context_policy') ? app(WorkContext::class)->shared($request) : null, + 'enabledWorkContexts' => app(WorkContext::class)->policy($actor)['enabled'] ?? [], + 'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR), 'work_contexts' => json_decode($p->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR)]) : [], 'memberships' => $ready ? DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->get(['user_id', 'profile_id', 'maintenance', 'expires_at', 'reason']) : [], 'users' => DB::table('users')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id']), 'departments' => DB::table('departments')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']), @@ -49,8 +54,11 @@ class OrganizationConfigurationController extends Controller abort_unless(Schema::hasTable('organization_access'), 503, 'A instalação da parametrização ainda não está concluída.'); $rules = ['version' => 'required|integer|min:1', 'tenant_id' => 'prohibited', 'purchasing_role' => 'prohibited']; if ($command === 'profile') { + $rules += ['work_contexts' => 'sometimes|array', 'work_contexts.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))]]; $rules += ['id' => 'nullable|integer|min:1', 'name' => ['required', 'string', 'max:120', Rule::unique('organization_profiles')->where('tenant_id', $actor->tenant_id)->ignore($request->integer('id'))], 'permissions' => 'present|array', 'permissions.*' => ['string', 'distinct', Rule::in(array_keys(OrganizationAccess::PERMISSIONS))]]; + } elseif ($command === 'work_contexts') { + $rules += ['enabled' => 'present|array', 'enabled.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))], 'reason' => 'required|string|min:10|max:4000', 'confirm' => 'accepted']; } elseif ($command === 'user') { $rules += ['name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|string|min:12|confirmed', 'department_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'reason' => 'required|string|min:10|max:4000']; diff --git a/app/Http/Controllers/PeopleReportController.php b/app/Http/Controllers/PeopleReportController.php new file mode 100644 index 0000000..6fc3499 --- /dev/null +++ b/app/Http/Controllers/PeopleReportController.php @@ -0,0 +1,156 @@ +user('web'); + abort_unless($actor instanceof User, 403); + $kinds = app(PeopleHiring::class)->kinds($actor); + abort_unless($kinds !== [], 403); + $filters = $request->validate([ + 'view' => 'nullable|in:processes,applications,selections,deadlines', + 'deadline_window' => 'nullable|in:all,overdue,today,future,undated', + 'deadline_page' => 'nullable|integer|min:1|max:100000', + 'kind' => ['nullable', Rule::in($kinds)], 'status' => ['nullable', Rule::in(PeopleWorkdesk::STATES)], + 'search' => 'nullable|string|max:100', 'from' => 'nullable|date_format:Y-m-d', + 'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')], + ]); + $query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor); + foreach (['kind', 'status'] as $field) { + if (! empty($filters[$field])) { + $query->where($field, $filters[$field]); + } + } + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->whereLike('title', '%'.$search.'%'); + if (preg_match('/^(?:PES-)?([1-9][0-9]*)$/D', $search, $matches)) { + $id = filter_var($matches[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($id !== false) { + $q->orWhere('id', $id); + } + } + }); + } + if (! empty($filters['from'])) { + $query->whereDate('created_at', '>=', $filters['from']); + } + if (! empty($filters['to'])) { + $query->whereDate('created_at', '<=', $filters['to']); + } + $columns = ['id', 'title', 'kind', 'status', 'closes_on', 'created_at']; + $page = $request->routeIs('people.reports.csv') ? null : (clone $query)->latest('id')->paginate(25, $columns)->withQueryString(); + $applications = ($filters['view'] ?? 'processes') === 'applications'; + $selections = ($filters['view'] ?? 'processes') === 'selections'; + $deadlines = ($filters['view'] ?? 'processes') === 'deadlines'; + $deadlineReport = $deadlines ? app(PeopleDeadlineReport::class)->build(clone $query, $actor, $filters['deadline_window'] ?? 'all') : null; + if ($deadlineReport !== null && ! $request->routeIs('people.reports.csv')) { + $number = (int) ($filters['deadline_page'] ?? 1); + $pager = (new LengthAwarePaginator(array_slice($deadlineReport['items'], ($number - 1) * 25, 25), $deadlineReport['total_items'], 25, $number, + ['path' => $request->url(), 'pageName' => 'deadline_page']))->appends($request->except('deadline_page')); + $deadlineReport['items'] = $pager->items(); + $deadlineReport['pagination'] = ['current_page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'prev_page_url' => $pager->previousPageUrl(), 'next_page_url' => $pager->nextPageUrl()]; + } + $metrics = []; + if ($applications || $selections) { + $metricQuery = clone $query; + if ($page !== null) { + $metricQuery->whereIn('id', $page->getCollection()->modelKeys()); + } + foreach ($metricQuery->orderBy('id')->lazyById(100) as $process) { + $metrics[$process->id] = $applications ? app(PeopleApplicationReport::class)->counts($process, $actor) : app(PeopleSelectionReport::class)->facts($process, $actor); + } + } + if ($request->routeIs('people.reports.csv') && $deadlineReport !== null) { + $response = $this->deadlineCsv($deadlineReport); + } elseif ($request->routeIs('people.reports.csv')) { + // Materialise within the read-only snapshot; streaming runs after the transaction. + $rows = (clone $query)->orderBy('id')->get($columns); + $response = response()->streamDownload(function () use ($rows, $applications, $selections, $metrics) { + $file = fopen('php://output', 'w'); + if ($file === false) { + throw new \RuntimeException('Não foi possível abrir a exportação.'); + } + fwrite($file, "\xEF\xBB\xBF"); + $extra = ['Registadas para avaliação', 'Avaliação completa', 'Por concluir avaliação', 'Sem avaliação', 'Avaliação em curso', 'Base da avaliação', 'Recebidas no portal', 'Admitidas para análise no portal', 'Não admitidas no portal', 'Triagem pendente']; + $selectionColumns = ['Situação da selecção', 'Proposta em', 'Decisão em', 'Pendência', 'Acesso à minuta', 'Referência da minuta', 'Minuta preparada em', 'Início previsto na minuta']; + fputcsv($file, ['Referência', 'Título', 'Percurso', 'Estado', 'Fecho de candidaturas', 'Criado em', ...($applications ? $extra : ($selections ? $selectionColumns : []))], ';', '"', ''); + foreach ($rows as $row) { + $title = preg_match('/^[\s]*[=+@-]/u', $row->title) ? "'".$row->title : $row->title; + $counts = $metrics[$row->id] ?? []; + $values = $applications ? array_values(array_map(fn ($value) => $value === null ? 'Sem acesso' : ($value === 'selection_snapshot' ? 'Selecção preservada' : ($value === 'current_eligibility' ? 'Elegibilidade actual' : $value)), $counts)) : []; + if ($selections) { + $values = array_values(array_map(fn ($value) => $value === null ? 'Não registado' : (is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value), $counts)); + } + fputcsv($file, ['PES-'.$row->id, $title, $row->kind === 'service' ? 'Serviços individuais' : 'Recrutamento e selecção', self::states()[$row->status] ?? $row->status, $row->closes_on, $row->created_at?->toDateString(), ...$values], ';', '"', ''); + } + fclose($file); + }, 'relatorio-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } else { + $summary = (clone $query)->selectRaw('kind, status, count(*) as total')->groupBy('kind', 'status')->get(); + Inertia::encryptHistory(); + $response = Inertia::render('people/reports', [ + 'filters' => $filters, 'kinds' => $kinds, 'summary' => $summary, + 'applicationMetrics' => $applications ? $metrics : [], + 'selectionMetrics' => $selections ? $metrics : [], + 'deadlineReport' => $deadlineReport, + 'generatedAt' => now()->toIso8601String(), + 'processes' => $page, + ])->toResponse($request); + } + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + }); + } + + /** @param array $report */ + private function deadlineCsv(array $report): Response + { + return response()->streamDownload(function () use ($report) { + $file = fopen('php://output', 'w'); + if ($file === false) { + throw new \RuntimeException('Não foi possível abrir a exportação.'); + } + fwrite($file, "\xEF\xBB\xBF"); + fputcsv($file, ['Referência', 'Processo', 'Categoria', 'Intervenção', 'Responsabilidade', 'Prazo', 'Dias de atraso', 'Consulta em', 'Filtro de prazo'], ';', '"', ''); + $windows = ['all' => 'Todos', 'overdue' => 'Vencidos', 'today' => 'Hoje', 'future' => 'Futuros', 'undated' => 'Sem prazo definido']; + foreach ($report['items'] as $item) { + $values = [$item['reference'], $item['title'], $item['category'], $item['label'], $item['responsibility'], $item['due_on'] ?? 'Sem prazo definido', $item['days_overdue'] ?? 'Não determinável', $report['as_of'], $windows[$report['window']]]; + $values = array_map(fn ($value) => is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value, $values); + fputcsv($file, $values, ';', '"', ''); + } + fclose($file); + }, 'relatorio-prazos-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + /** @return array */ + private static function states(): array + { + return ['draft' => 'Abertura por conferir', 'open' => 'Recepção de candidaturas', 'assessment' => 'Avaliação', + 'selection_review' => 'Selecção por conferir', 'selected' => 'Selecção aprovada', 'contract_draft' => 'Minuta contratual preparada']; + } +} diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php index 6cc1e13..b97669e 100644 --- a/app/Http/Controllers/RequisitionController.php +++ b/app/Http/Controllers/RequisitionController.php @@ -12,14 +12,19 @@ use App\Models\SparePart; use App\Models\StockItem; use App\Models\TechnicalReview; use App\Models\Tenant; +use App\Models\User; +use App\Services\ApprovalRouting; use App\Services\ComparisonPolicy; use App\Services\ConflictGuard; use App\Services\EquipmentWorkflow; use App\Services\OrganizationAccess; 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\Schema; use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; @@ -79,6 +84,9 @@ class RequisitionController extends Controller return Inertia::render('requisitions/show', [ 'requisition' => $requisition, + 'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $request->user()), + 'canRefreshRouting' => $requisition->status === 'pending' && $request->user()->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($request->user()) && Schema::hasTable('workflow_notices'), + 'replenishmentSource' => app(SupplySuggestions::class)->origin($requisition), 'comparisonPolicy' => app(ComparisonPolicy::class)->forRequest($requisition), 'canEdit' => app(OrganizationAccess::class)->canRequest($request->user()) && $requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 'canDecide' => $requisition->canDecide($request->user()), @@ -140,7 +148,8 @@ class RequisitionController extends Controller public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse { - $workflow->submit($request->user(), $requisition); + $command = $request->validate(['operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1']); + $workflow->submit($request->user(), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null); return back(); } @@ -150,14 +159,21 @@ class RequisitionController extends Controller $data = $request->validate([ 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'], + 'operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1', ]); - $workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null); + $workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null); return back(); } public function dashboard(Request $request): Response { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $contexts = app(WorkContext::class); + if ($contexts->enforced($actor)) { + return Inertia::render('work-context', ['context' => $contexts->shared($request)]); + } $visible = Requisition::query()->visibleTo($request->user()); $pending = (clone $visible)->where('status', 'pending')->with('steps')->get(); diff --git a/app/Http/Controllers/StockController.php b/app/Http/Controllers/StockController.php index 174b496..79ef7b5 100644 --- a/app/Http/Controllers/StockController.php +++ b/app/Http/Controllers/StockController.php @@ -6,6 +6,7 @@ use App\Enums\PurchasingRole; use App\Models\StockItem; use App\Models\StockMovement; use App\Models\Warehouse; +use App\Services\InventoryLots; use App\Services\RequisitionWorkflow; use Brick\Math\BigDecimal; use Illuminate\Http\RedirectResponse; @@ -44,6 +45,7 @@ class StockController extends Controller $balances = (clone $movements)->selectRaw('stock_item_id, warehouse_id, SUM(quantity_milli) as quantity_milli')->groupBy('stock_item_id', 'warehouse_id')->get()->map(fn ($balance) => [ 'stock_item_id' => $balance->stock_item_id, 'warehouse_id' => $balance->warehouse_id, 'quantity_milli' => BigDecimal::of($balance->quantity_milli)->toInt(), + 'lot_control' => app(InventoryLots::class)->summary($balance->stock_item_id, $balance->warehouse_id), ]); return Inertia::render('stock/index', [ diff --git a/app/Http/Controllers/StockOperationController.php b/app/Http/Controllers/StockOperationController.php index 96f2c27..1b94038 100644 --- a/app/Http/Controllers/StockOperationController.php +++ b/app/Http/Controllers/StockOperationController.php @@ -8,6 +8,9 @@ use App\Models\StockItem; use App\Models\StockOperation; use App\Models\User; use App\Models\Warehouse; +use App\Services\InventoryCounts; +use App\Services\InventoryLots; +use App\Services\InventoryReversals; use App\Services\StockWorkflow; use Brick\Math\BigDecimal; use Illuminate\Http\JsonResponse; @@ -15,6 +18,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; @@ -22,31 +26,47 @@ class StockOperationController extends Controller { public function index(Request $request, StockWorkflow $workflow): Response { - abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403); + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + abort_unless($actor->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, 'inventory_adjust_review']), 403); $items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']); $warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']); $balances = []; + $lotCounts = []; foreach ($items as $item) { foreach ($warehouses as $warehouse) { - $balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id)]; + $balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id), + 'lot_control' => app(InventoryLots::class)->summary($item->id, $warehouse->id)]; + if ($actor->hasFunctions([PurchasingRole::Warehouse]) && app(InventoryLots::class)->setup($item->id, $warehouse->id) !== null) { + try { + $source = app(InventoryCounts::class)->source($item->id, $warehouse->id); + $lotCounts[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$source]; + } catch (ValidationException) { + // The summary still reports the divergence; no count can use an invented baseline. + } + } } } return Inertia::render('stock/operations', [ + 'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->map(fn ($r) => $r->data['reference']), 'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances, - 'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']), + 'users' => User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']), 'operations' => StockOperation::query()->latest('id')->paginate(20), 'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(), - 'canOperate' => $request->user()->hasFunctions([PurchasingRole::Warehouse]), + 'canOperate' => $actor->hasFunctions([PurchasingRole::Warehouse]), + 'canAdjust' => app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 'lotCounts' => $lotCounts, 'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']), ]); } public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse { - abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse]), 403); + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + abort_unless($actor->hasFunctions($request->input('kind') === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]), 403); $request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]); - $tenant = $request->user()->tenant_id; + $tenant = $actor->tenant_id; $data = $request->validate([ 'kind' => ['required', Rule::in(['issue', 'transfer', 'count', 'adjustment'])], 'reference' => 'required|string|max:100', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|max:4000', @@ -59,10 +79,31 @@ class StockOperationController extends Controller 'quantity' => ['exclude_if:kind,adjustment', 'required', 'regex:/^\d{1,9}(\.\d{1,3})?$/', $request->input('kind') === 'count' ? 'gte:0' : 'gt:0'], 'ledger_version' => 'exclude_unless:kind,count|required|integer|min:0', 'count_id' => ['exclude_unless:kind,adjustment', 'required', 'integer', Rule::exists('stock_operations', 'id')->where('tenant_id', $tenant)->where('kind', 'count')], + 'lot_version' => 'exclude_unless:kind,count|nullable|string|size:64', + 'lot_counts' => 'exclude_unless:kind,count|sometimes|array|max:500', + 'lot_counts.*.lot_id' => 'nullable|integer|min:1', 'lot_counts.*.new_key' => 'nullable|uuid', + 'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], + 'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean', ]); if (empty($data['equipment_id'])) { unset($data['equipment_id']); } + if (empty($data['lot_version'])) { + unset($data['lot_version']); + } + if (empty($data['lot_counts']) && empty($data['lot_version'])) { + unset($data['lot_counts']); + } + if (isset($data['lot_counts'])) { + $data['lot_counts'] = array_map(function (array $row) { + if (! empty($row['lot_id'])) { + $row['lot_id'] = (int) $row['lot_id']; + } + $row['quantity'] = (string) BigDecimal::of($row['quantity'])->toScale(3); + + return $row; + }, $data['lot_counts']); + } foreach (['responsible_id', 'warehouse_id', 'stock_item_id', 'destination_warehouse_id', 'ledger_version', 'count_id', 'equipment_id'] as $key) { if (isset($data[$key])) { $data[$key] = (int) $data[$key]; @@ -70,7 +111,7 @@ class StockOperationController extends Controller } $data['quantity'] = isset($data['quantity']) ? (string) BigDecimal::of($data['quantity'])->toScale(3) : '0.000'; ksort($data); - $operation = $workflow->record($request->user(), $data); + $operation = $workflow->record($actor, $data); return $request->expectsJson() ? response()->json(['id' => $operation->id, 'created' => $operation->wasRecentlyCreated], $operation->wasRecentlyCreated ? 201 : 200) : back(); } diff --git a/app/Http/Controllers/SupplyPlanningController.php b/app/Http/Controllers/SupplyPlanningController.php new file mode 100644 index 0000000..9fefd37 --- /dev/null +++ b/app/Http/Controllers/SupplyPlanningController.php @@ -0,0 +1,170 @@ +user('web'); + abort_unless($actor instanceof User, 403); + $view = $request->validate(['view' => 'nullable|in:agreements,calendar,imports,alternatives,reservations,deliveries,suggestions,history'])['view'] ?? 'agreements'; + if ($view === 'history') { + return $this->history($actor, $planning); + } + if ($view === 'suggestions') { + return $this->suggestions($actor, $planning); + } + if (in_array($view, ['alternatives', 'reservations', 'deliveries'], true)) { + return $this->coordination($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(); + $projections = []; + if ($view === 'calendar' && $ready) { + foreach ($records->where('state', 'approved') as $record) { + $projections[$record->record_key] = $planning->calendar($record, today()->toDateString()); + } + } + $prepare = $planning->allows($actor, $import ? 'import_prepare' : 'supply_prepare'); + + return Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready, 'records' => $records, 'projections' => $projections, + 'asOf' => today()->toDateString(), + 'histories' => $ready && $prepare && $kind === 'policy' ? $planning->latest('history')->where('state', 'approved')->latest('id')->limit(100)->get()->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']) : [], + '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']) : [], + ]); + } + + private function history(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(); + + return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, '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 + { + $prepare = $planning->allows($actor, 'supply_suggest_prepare'); + $review = $planning->allows($actor, 'supply_suggest_review'); + $convertPermission = $planning->allows($actor, 'supply_suggest_convert'); + $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(); + $policies = []; + if ($ready && $prepare) { + foreach ($planning->latest('policy')->where('state', 'approved')->latest('id')->limit(100)->get() 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()); + $option += ['unit_price' => $basis['unit_price'], 'tax_rate' => $basis['tax_rate'], 'quantity_milli' => $basis['projection']['quantity_milli'] ?? 0, + 'delivery_on' => $basis['projection']['risk_date'] ?? null, 'unit' => $basis['item']['unit']]; + } catch (ValidationException $e) { + $option['issue'] = $e->getMessage(); + } + $policies[] = $option; + } + } + $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, + '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)]), + 'history' => $ready ? SupplyRecord::query()->where('kind', 'suggestion')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [], + ]); + } + + private function coordination(User $actor, string $view, SupplyPlanning $planning): Response + { + [$kind, $prepare, $review] = match ($view) { + 'alternatives' => ['alternative', 'supply_technical_prepare', 'supply_technical_review'], + 'reservations' => ['reservation', 'supply_reserve', 'supply_reserve'], + '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); + $ready = Schema::hasTable('supply_records'); + $records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect(); + $canPrepare = $planning->allows($actor, $prepare); + $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(); + $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(); + $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, + '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, + 'canPrepare' => $canPrepare, 'canReview' => $planning->allows($actor, $review), + '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']) : [], + 'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [], + ]); + } + + public function save(Request $request, string $kind, SupplyPlanning $planning): RedirectResponse + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $planning->save($actor, $kind, $request->all()); + + return back(); + } +} diff --git a/app/Http/Controllers/WarehouseAvailabilityController.php b/app/Http/Controllers/WarehouseAvailabilityController.php new file mode 100644 index 0000000..7f3169b --- /dev/null +++ b/app/Http/Controllers/WarehouseAvailabilityController.php @@ -0,0 +1,43 @@ +user('web'); + abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'warehouse_availability_read']), 403); + + return DB::transaction(function () use ($request, $actor, $availability) { + if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) { + DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + } + $filters = $request->validate(['warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], + 'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:100000']); + $warehouse = empty($filters['warehouse_id']) ? null : Warehouse::query()->whereKey($filters['warehouse_id'])->firstOrFail(); + $planning = $actor->hasFunctions(['admin', 'supply_read', 'supply_reserve', 'warehouse_availability_read']); + $query = StockItem::query(); + if (! empty($filters['search'])) { + $query->where(fn ($q) => $q->whereLike('code', '%'.$filters['search'].'%')->orWhereLike('name', '%'.$filters['search'].'%')); + } + $page = $warehouse === null ? null : $query->orderBy('code')->paginate(25)->withQueryString()->through(fn ($item) => $availability->row($item, $warehouse, $planning)); + $response = Inertia::render('stock/availability', ['filters' => $filters, 'planningVisible' => $planning, 'rows' => $page, + 'warehouse' => $warehouse?->only(['id', 'code', 'name']), 'checkedAt' => now()->toIso8601String(), + 'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name'])])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + }); + } +} diff --git a/app/Http/Controllers/WarehouseRequestController.php b/app/Http/Controllers/WarehouseRequestController.php new file mode 100644 index 0000000..d8af021 --- /dev/null +++ b/app/Http/Controllers/WarehouseRequestController.php @@ -0,0 +1,87 @@ +user('web'); + abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403); + $prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor); + $ready = Schema::hasTable('warehouse_requests'); + $query = WarehouseRequest::query(); + if (! $actor->hasFunctions(['admin', 'warehouse_request_read']) && ! ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))) { + $query->where(function ($q) use ($actor) { + $q->where('created_by', $actor->id); + if ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null) { + $q->orWhere('snapshot->requester->department_id', $actor->department_id); + } + }); + } + Inertia::encryptHistory(); + $response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'), + 'records' => $ready ? $query->latest('id')->paginate(15, ['id', 'status', 'snapshot', 'created_at'])->withQueryString()->through(fn ($r) => ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => app(WarehouseFulfilment::class)->progress($r)]) : null, + 'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], + 'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], + 'costCenters' => $prepare ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [], + 'equipment' => $prepare ? Equipment::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'description']) : []])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function store(Request $request, WarehouseRequests $service): Response + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $service->draft($actor, $request->all()); + + return redirect('/stock/requisicoes'); + } + + public function show(Request $request, int $id, WarehouseFulfilment $service): Response + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $r = WarehouseRequest::query()->findOrFail($id); + $owner = $r->created_by === $actor->id && $actor->hasFunctions(['warehouse_request_prepare']); + $review = $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $r->snapshot['requester']['department_id']; + $deliver = $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']); + abort_unless($owner || $review || $deliver || $actor->hasFunctions(['admin', 'warehouse_request_read']), 403); + $p = $service->progress($r); + $maintenance = app(OrganizationAccess::class)->maintenanceOnly($actor); + Inertia::encryptHistory(); + $response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'), + 'canSubmit' => ! $maintenance && $owner && $p['state'] === 'draft', 'canReview' => ! $maintenance && $review && ! $owner && $r->created_by !== $actor->id && $p['state'] === 'submitted', + 'canDeliver' => ! $maintenance && $deliver && $r->created_by !== $actor->id && $p['reviewer_id'] !== $actor->id && in_array($p['state'], ['authorized', 'partial'], true), + 'canManageReservation' => ! $maintenance && $review && ! $owner && in_array($p['state'], ['authorized', 'partial'], true), + 'canReceive' => ! $maintenance && $owner])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function command(Request $request, int $id, WarehouseFulfilment $service): Response + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $service->command($actor, $id, $request->all()); + + return redirect('/stock/requisicoes/'.$id); + } +} diff --git a/app/Http/Controllers/WorkContextController.php b/app/Http/Controllers/WorkContextController.php new file mode 100644 index 0000000..8c2613c --- /dev/null +++ b/app/Http/Controllers/WorkContextController.php @@ -0,0 +1,28 @@ +user('web'); + abort_unless($actor instanceof User, 403); + $data = $request->validate(['area' => ['required', Rule::in(['procurement', 'people'])], 'option' => ['nullable', Rule::in(array_keys(WorkContext::OPTIONS))]]); + $available = array_values(array_filter($contexts->available($actor), fn ($key) => WorkContext::OPTIONS[$key]['area'] === $data['area'])); + $key = $data['option'] ?? (count($available) === 1 ? $available[0] : null); + abort_unless($key !== null && in_array($key, $available, true), 403, 'Escolha uma opção configurada, contratada e autorizada.'); + $state = ['user_id' => $actor->id, 'tenant_id' => $actor->tenant_id, 'key' => $key]; + app(RequisitionWorkflow::class)->audit($actor, 'work_context.selected', null, $request->session()->get('work_context'), $state); + $request->session()->put('work_context', $state); + + return redirect('/dashboard'); + } +} diff --git a/app/Http/Controllers/WorkflowNoticeController.php b/app/Http/Controllers/WorkflowNoticeController.php new file mode 100644 index 0000000..14f340a --- /dev/null +++ b/app/Http/Controllers/WorkflowNoticeController.php @@ -0,0 +1,51 @@ +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']); + $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); + $response = Inertia::render('workflow/notices', ['ready' => Schema::hasTable('workflow_notices'), 'unread' => $unread, 'view' => $filter['view'] ?? 'all', + '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'); + + return $response; + } + + public function read(Request $request, int $notice, WorkflowNotices $notices): Response + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $notices->read($actor, $notice); + + return redirect('/requisicoes/avisos'); + } + + public function refresh(Request $request, int $requisition, WorkflowNotices $notices): Response + { + $actor = $request->user('web'); + abort_unless($actor instanceof User, 403); + $notices->refresh($actor, $requisition, $request->all()); + + return redirect('/requisicoes/'.$requisition); + } +} diff --git a/app/Http/Middleware/EnsureOrganizationAccess.php b/app/Http/Middleware/EnsureOrganizationAccess.php index 399ad9e..5b57aca 100644 --- a/app/Http/Middleware/EnsureOrganizationAccess.php +++ b/app/Http/Middleware/EnsureOrganizationAccess.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\User; use App\Services\OrganizationAccess; use App\Services\RequisitionWorkflow; use Closure; @@ -12,14 +13,15 @@ class EnsureOrganizationAccess { public function handle(Request $request, Closure $next): Response { - $user = $request->user(); - if ($user !== null && $user->tenant_id !== null) { + $user = $request->user('web'); + if ($user instanceof User && $user->tenant_id !== null) { abort_unless(app(OrganizationAccess::class)->permissions($user) !== [], 403, 'Não existe uma atribuição de acesso válida. Contacte os administradores da empresa.'); - abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe(), 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.'); + $contextSelection = $request->isMethod('POST') && $request->routeIs('work-context.select'); + abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe() && ! $contextSelection, 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.'); } $response = $next($request); - if ($user !== null && app(OrganizationAccess::class)->maintenanceOnly($user)) { + if ($user instanceof User && app(OrganizationAccess::class)->maintenanceOnly($user)) { app(RequisitionWorkflow::class)->audit($user, 'maintenance.consulted', null, null, ['path' => $request->path(), 'status' => $response->getStatusCode()]); } diff --git a/app/Http/Middleware/EnsureWorkContext.php b/app/Http/Middleware/EnsureWorkContext.php new file mode 100644 index 0000000..bdcd892 --- /dev/null +++ b/app/Http/Middleware/EnsureWorkContext.php @@ -0,0 +1,28 @@ +user('web'); + if ($user instanceof User && $contexts->enforced($user) && ($area = WorkContext::area($request->path())) !== null) { + $key = $contexts->active($request); + abort_unless($key !== null && WorkContext::OPTIONS[$key]['area'] === $area, 403, 'Seleccione uma área e opção autorizadas no Painel Inicial.'); + if (! WorkContext::OPTIONS[$key]['operational'] && $request->isMethod('GET')) { + return redirect('/dashboard'); + } + abort_unless(WorkContext::OPTIONS[$key]['operational'], 409, 'O percurso operacional desta opção ainda está em implementação.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index b233e6e..10365bc 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,7 +5,9 @@ namespace App\Http\Middleware; use App\Models\User; use App\Services\OrganizationAccess; use App\Services\SupplierPayables; +use App\Services\WorkContext; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Schema; use Inertia\Middleware; class HandleInertiaRequests extends Middleware @@ -49,6 +51,8 @@ class HandleInertiaRequests extends Middleware return [ ...parent::share($request), 'name' => config('app.name'), + 'internalRequisitionReady' => $actor !== null && Schema::hasTable('requisition_commands'), + 'workContext' => $actor === null ? null : app(WorkContext::class)->shared($request), 'canRequest' => $actor !== null && app(OrganizationAccess::class)->canRequest($actor), 'auth' => [ 'user' => $actor, diff --git a/app/Http/Requests/ConfirmReceiptRequest.php b/app/Http/Requests/ConfirmReceiptRequest.php index 0cfb6a8..bff4082 100644 --- a/app/Http/Requests/ConfirmReceiptRequest.php +++ b/app/Http/Requests/ConfirmReceiptRequest.php @@ -28,6 +28,11 @@ class ConfirmReceiptRequest extends FormRequest 'items.*.accepted_quantity' => ['required', 'string', 'numeric', 'min:0', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'], 'items.*.quality_status' => ['required', Rule::in(['compliant', 'reservations', 'nonconforming'])], 'items.*.notes' => 'nullable|string|max:3000', + 'items.*.lots' => 'sometimes|array|max:100', + 'items.*.lots.*.code' => 'nullable|string|max:100', + 'items.*.lots.*.expires_on' => 'nullable|date_format:Y-m-d', + 'items.*.lots.*.no_expiry' => 'sometimes|boolean', + 'items.*.lots.*.quantity' => ['nullable', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.stock_item_id' => ['nullable', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $this->user()?->tenant_id)], 'tenant_id' => 'prohibited', 'received_by' => 'prohibited', 'status' => 'prohibited', 'snapshot' => 'prohibited', ]; diff --git a/app/Http/Requests/StoreRequisitionRequest.php b/app/Http/Requests/StoreRequisitionRequest.php index 085a56e..9b0b5d6 100644 --- a/app/Http/Requests/StoreRequisitionRequest.php +++ b/app/Http/Requests/StoreRequisitionRequest.php @@ -18,6 +18,8 @@ class StoreRequisitionRequest extends FormRequest public function rules(): array { return [ + 'operation_key' => 'nullable|uuid', + 'process_version' => 'nullable|integer|min:1', 'cost_center_id' => ['required', 'integer', Rule::exists('cost_centers', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)], 'priority' => ['required', Rule::in(['normal', 'high', 'urgent'])], 'needed_by' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'], diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php index 08854c8..06c1aa6 100644 --- a/app/Models/AuditLog.php +++ b/app/Models/AuditLog.php @@ -6,6 +6,10 @@ use App\Models\Concerns\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use LogicException; +/** + * @property array|null $before + * @property array $after + */ class AuditLog extends Model { use BelongsToTenant; diff --git a/app/Models/Concerns/HasWorkContext.php b/app/Models/Concerns/HasWorkContext.php new file mode 100644 index 0000000..5806836 --- /dev/null +++ b/app/Models/Concerns/HasWorkContext.php @@ -0,0 +1,43 @@ +bound('request') || ! request()->hasSession() || request()->is('candidaturas/*')) { + return; + } + $actor = request()->user('web'); + if ($actor instanceof User && app(WorkContext::class)->enforced($actor)) { + $key = app(WorkContext::class)->active(request()); + $query->where($query->getModel()->qualifyColumn('work_context_key'), $key ?? '__no_context__'); + } + }); + static::creating(function (Model $model) { + if (! app()->bound('request') || ! request()->hasSession()) { + return; + } + $actor = request()->user('web'); + if ($actor instanceof User && app(WorkContext::class)->enforced($actor)) { + $key = app(WorkContext::class)->active(request()); + $area = $model->getTable() === 'people_processes' ? 'people' : 'procurement'; + abort_unless($key !== null && WorkContext::OPTIONS[$key]['operational'] && WorkContext::OPTIONS[$key]['area'] === $area, 403); + $model->setAttribute('work_context_key', $key); + } + }); + static::updating(function (Model $model) { + if ($model->isDirty('work_context_key')) { + throw ValidationException::withMessages(['context' => 'O contexto de origem do processo não pode ser alterado.']); + } + }); + } +} diff --git a/app/Models/GoodsReceiptItem.php b/app/Models/GoodsReceiptItem.php index f9d2bec..3f84b9f 100644 --- a/app/Models/GoodsReceiptItem.php +++ b/app/Models/GoodsReceiptItem.php @@ -6,6 +6,7 @@ use App\Models\Concerns\AppendOnlyInventoryRecord; use App\Models\Concerns\BelongsToTenant; use Illuminate\Database\Eloquent\Model; +/** @property array $snapshot */ class GoodsReceiptItem extends Model { use AppendOnlyInventoryRecord, BelongsToTenant; diff --git a/app/Models/InventoryLotAllocation.php b/app/Models/InventoryLotAllocation.php new file mode 100644 index 0000000..eb02103 --- /dev/null +++ b/app/Models/InventoryLotAllocation.php @@ -0,0 +1,22 @@ + throw new \LogicException('Preservar repartições de lotes.')); + static::deleting(fn () => throw new \LogicException('Preservar repartições de lotes.')); + } +} diff --git a/app/Models/PeopleProcess.php b/app/Models/PeopleProcess.php index ac0f962..553f59c 100644 --- a/app/Models/PeopleProcess.php +++ b/app/Models/PeopleProcess.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\HasWorkContext; use Illuminate\Database\Eloquent\Model; /** @@ -22,6 +23,7 @@ use Illuminate\Database\Eloquent\Model; class PeopleProcess extends Model { use BelongsToTenant; + use HasWorkContext; protected $guarded = ['id']; diff --git a/app/Models/Requisition.php b/app/Models/Requisition.php index 56607f2..f68e65e 100644 --- a/app/Models/Requisition.php +++ b/app/Models/Requisition.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\PurchasingRole; use App\Models\Concerns\BelongsToTenant; +use App\Models\Concerns\HasWorkContext; use App\Services\ConflictGuard; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; +use Tempo\Purchasing\Internal\InternalRequisitions; /** * @property int $id @@ -31,6 +33,7 @@ use Illuminate\Support\Carbon; class Requisition extends Model { use BelongsToTenant; + use HasWorkContext; protected $guarded = ['id']; @@ -81,18 +84,14 @@ class Requisition extends Model public function canDecide(User $user): bool { - if ($this->tenant_id !== $user->tenant_id || $this->status !== 'pending' || $this->requester_id === $user->id) { + if ($this->tenant_id !== $user->tenant_id) { return false; } - if ($this->steps->contains('approver_id', $user->id)) { - return false; - } + $next = InternalRequisitions::nextStep(['status' => $this->status, 'requester_id' => $this->requester_id, 'steps' => $this->steps->toArray()], $user->id); - $next = $this->steps->firstWhere('decision', 'pending'); - - return $next !== null && $user->hasFunctions([$next->role]) - && ($next->role !== 'department_head' || $this->department_id === $user->department_id) + return $next !== null && $user->hasFunctions([$next['role']]) + && ($next['role'] !== 'department_head' || $this->department_id === $user->department_id) && ! app(ConflictGuard::class)->participationBlocked($user, $this); } } diff --git a/app/Models/StockMovement.php b/app/Models/StockMovement.php index 73d6602..e744c45 100644 --- a/app/Models/StockMovement.php +++ b/app/Models/StockMovement.php @@ -4,8 +4,15 @@ namespace App\Models; use App\Models\Concerns\AppendOnlyInventoryRecord; use App\Models\Concerns\BelongsToTenant; +use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Model; +/** + * @property CarbonInterface $moved_at + * @property int|null $equipment_id + * @property int|null $spare_part_id + * @property array $snapshot + */ class StockMovement extends Model { use AppendOnlyInventoryRecord, BelongsToTenant; diff --git a/app/Models/StockOperation.php b/app/Models/StockOperation.php index 13d2748..63d3174 100644 --- a/app/Models/StockOperation.php +++ b/app/Models/StockOperation.php @@ -4,12 +4,14 @@ namespace App\Models; use App\Models\Concerns\AppendOnlyInventoryRecord; use App\Models\Concerns\BelongsToTenant; +use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Model; /** * @property array $snapshot * @property int|null $equipment_id * @property int|null $spare_part_id + * @property CarbonInterface $recorded_at */ class StockOperation extends Model { diff --git a/app/Models/SupplyRecord.php b/app/Models/SupplyRecord.php new file mode 100644 index 0000000..d4dec94 --- /dev/null +++ b/app/Models/SupplyRecord.php @@ -0,0 +1,34 @@ + $data + * @property array $snapshot + * @property int $version + * @property string $record_key + * @property \Illuminate\Support\Carbon $created_at + */ +class SupplyRecord extends Model +{ + use BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + /** @return array */ + protected function casts(): array + { + return ['data' => 'array', 'snapshot' => 'array', 'created_at' => 'datetime']; + } + + protected static function booted(): void + { + static::updating(fn () => throw new \LogicException('Criar uma nova versão.')); + static::deleting(fn () => throw new \LogicException('Preservar histórico.')); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 6ecb40d..f8e1eed 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -11,7 +11,9 @@ use Illuminate\Database\Eloquent\Model; * @property int $second_limit_minor * @property bool $approval_includes_tax * @property bool $rules_provisional + * @property string|null $default_tax_rate * @property array|null $comparison_policy + * @property array{revision:int,enabled:list}|null $work_context_policy * @property array{revision:int,configured:bool,procurement_brief:bool,people_brief:bool,ai_plan?:array}|null $intelligence_policy */ class Tenant extends Model @@ -20,6 +22,6 @@ class Tenant extends Model protected function casts(): array { - return ['approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean', 'default_tax_rate' => 'decimal:2', 'comparison_policy' => 'array', 'intelligence_policy' => 'array']; + return ['work_context_policy' => 'array', 'approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean', 'default_tax_rate' => 'decimal:2', 'comparison_policy' => 'array', 'intelligence_policy' => 'array']; } } diff --git a/app/Models/WarehouseRequest.php b/app/Models/WarehouseRequest.php new file mode 100644 index 0000000..696b39a --- /dev/null +++ b/app/Models/WarehouseRequest.php @@ -0,0 +1,23 @@ + $snapshot */ +class WarehouseRequest extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant, HasWorkContext; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['snapshot' => 'array']; + } +} diff --git a/app/Models/WarehouseRequestEvent.php b/app/Models/WarehouseRequestEvent.php new file mode 100644 index 0000000..adb6e54 --- /dev/null +++ b/app/Models/WarehouseRequestEvent.php @@ -0,0 +1,22 @@ + $data */ +class WarehouseRequestEvent extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['data' => 'array']; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9ff329b..d7f4ac0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,6 +15,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + require_once base_path('packages/purchasing/bootstrap.php'); $package = config('common-budget.path'); require_once $package.'/bootstrap.php'; $payables = config('common-payables.path'); diff --git a/app/Services/ApprovalRouting.php b/app/Services/ApprovalRouting.php new file mode 100644 index 0000000..e4bc1bc --- /dev/null +++ b/app/Services/ApprovalRouting.php @@ -0,0 +1,31 @@ + */ + public function current(Requisition $requisition, User $viewer): array + { + abort_unless($viewer->tenant_id === $requisition->tenant_id, 404); + Requisition::query()->visibleTo($viewer)->findOrFail($requisition->id); + $step = $requisition->status === 'pending' ? $requisition->steps->firstWhere('decision', 'pending') : null; + if ($step === null) { + return ['status' => 'inactive', 'step_id' => null, 'role' => null, 'people' => [], 'checked_at' => now()->toIso8601String()]; + } + $people = []; + foreach (User::query()->where('tenant_id', $requisition->tenant_id)->orderBy('name')->orderBy('id')->get() as $person) { + if (! app(OrganizationAccess::class)->maintenanceOnly($person) && $requisition->canDecide($person)) { + $people[] = $person->only(['id', 'name']); + } + } + + return ['status' => $people === [] ? 'unavailable' : 'available', 'step_id' => $step->id, 'role' => $step->role, + 'task_key' => 'requisition.approve.'.$step->role, + 'people' => $people, 'checked_at' => now()->toIso8601String()]; + } +} diff --git a/app/Services/AutomaticPurchases.php b/app/Services/AutomaticPurchases.php new file mode 100644 index 0000000..360111f --- /dev/null +++ b/app/Services/AutomaticPurchases.php @@ -0,0 +1,241 @@ +ready(); + } + + public function requester(User $executor, SupplyRecord $rule): User + { + $executor = app(CurrentActor::class)->lock($executor); + $current = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first(); + $this->check($current !== null && $current->id === $rule->id && $current->tenant_id === $executor->tenant_id && $current->state === 'approved', 'A parametrização automática deixou de estar activa.'); + $this->check(! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.'); + $user = User::query()->where('tenant_id', $executor->tenant_id)->lockForUpdate()->find($rule->data['requester_id']); + $this->check($user !== null && in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Requisitante sem competência vigente no contexto de compras internas.'); + $this->check(CostCenter::query()->whereKey($rule->data['cost_center_id'])->where('department_id', $user->department_id)->exists(), 'Centro de custo incompatível com o departamento actual do requisitante.'); + $this->check($user->only(['id', 'name', 'department_id']) === $rule->snapshot['requester'], 'O requisitante mudou; reveja a parametrização.'); + $center = CostCenter::query()->findOrFail($rule->data['cost_center_id']); + $this->check($center->only(['id', 'code', 'name', 'department_id']) === $rule->snapshot['cost_center'], 'O centro de custo mudou; reveja a parametrização.'); + + return $user; + } + + public function save(User $actor, array $input): SupplyRecord + { + abort_unless($this->ready(), 503, 'Instalação da reserva e reposição pendente.'); + + return DB::transaction(function () use ($actor, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $data = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:save,approve,suspend', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + abort_unless(app(SupplyPlanning::class)->allows($actor, $data['action'] === 'save' ? 'supply_prepare' : 'supply_review'), 403); + $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + $retry = SupplyRecord::query()->where('operation_key', $data['operation_key'])->first(); + if ($retry !== null) { + $this->check($retry->kind === 'auto_rule' && $retry->created_by === $actor->id && $retry->input_hash === $hash, 'Chave já utilizada com outro conteúdo.'); + + return $retry; + } + $old = empty($data['record_key']) ? null : app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $data['record_key'])->firstOrFail(); + $this->check($data['version'] === ($old?->version ?? 0), 'A parametrização mudou. Actualize a página.'); + $snapshot = $old?->snapshot ?? []; + $values = $old?->data ?? []; + if ($data['action'] === 'save') { + $values = Validator::make($data['data'], ['policy_key' => 'required|uuid', 'requester_id' => 'required|integer|min:1', 'cost_center_id' => 'required|integer|min:1', + 'unit_price' => 'nullable|string|regex:/^[0-9]{1,8}(\.[0-9]{1,2})?$/D|numeric|min:0.01', 'tax_rate' => 'nullable|string|numeric|min:0|max:100|regex:/^[0-9]{1,3}(\.[0-9]{1,2})?$/D', + 'needed_days' => 'required|integer|min:0|max:366', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today'])->validate(); + $basis = app(SupplySuggestions::class)->basis($values['policy_key'], today()->toDateString()); + $values['unit_price'] = $values['unit_price'] ?? $basis['unit_price']; + $values['tax_rate'] = $values['tax_rate'] ?? (string) $basis['tax_rate']; + $this->check($values['unit_price'] !== null, 'O acordo não fornece um preço em MZN. Registe uma estimativa válida.'); + $this->check($basis['policy']['purpose'] === 'internal', 'Esta parametrização destina-se às compras internas do SGC.'); + $this->check($basis['catalogue']['purchase_type'] === 'consumable', 'Seleccione um consumível publicado; equipamento/peças exigem o seu destino técnico.'); + $user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($values['requester_id']); + $this->check(in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Seleccione um requisitante com competência vigente em compras internas.'); + $center = CostCenter::query()->where('department_id', $user->department_id)->findOrFail($values['cost_center_id']); + $snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id, + 'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']]; + $duplicates = app(SupplyPlanning::class)->latest('auto_rule')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id'])->whereIn('state', ['draft', 'approved']); + if ($old !== null) { + $duplicates->where('record_key', '!=', $old->record_key); + } + $this->check(! $duplicates->exists(), 'Já existe uma parametrização neste artigo/armazém. Reveja a existente.'); + } else { + $this->check($old !== null && $old->state === ($data['action'] === 'approve' ? 'draft' : 'approved'), 'Estado incompatível com a intervenção.'); + $this->check($data['action'] !== 'approve' || $snapshot['prepared_by'] !== $actor->id, 'Quem preparou não pode activar a própria parametrização.'); + } + $row = $this->append($actor, 'auto_rule', $old, $data['action'] === 'save' ? 'draft' : ($data['action'] === 'approve' ? 'approved' : 'suspended'), $values, $snapshot, $data['reason'], $data['operation_key'], $hash); + if ($row->state === 'approved') { + $this->requester($actor, $row); + $this->basis($row); + $this->evaluate($actor, $row->stock_item_id, $row->warehouse_id); + } + + return $row; + }); + } + + private function basis(SupplyRecord $rule): array + { + $this->check($rule->data['valid_until'] >= today()->toDateString(), 'A estimativa/parametrização automática expirou.'); + $basis = app(SupplySuggestions::class)->basis($rule->data['policy_key'], today()->toDateString()); + $this->check($basis['policy_version'] === $rule->snapshot['policy_version'] && $basis['item'] === $rule->snapshot['item'] && $basis['warehouse'] === $rule->snapshot['warehouse'] && $basis['catalogue'] === $rule->snapshot['catalogue'], 'A política, o catálogo ou o armazém mudou. Reveja a parametrização automática.'); + + return $basis; + } + + /** Called only by successful stock/request mutations; reads never prepare documents. */ + public function signal(User $actor, int $item, int $warehouse): void + { + if (! $this->ready()) { + return; + } + DB::afterCommit(fn () => $this->evaluate($actor, $item, $warehouse)); + } + + public function evaluate(User $actor, int $item, int $warehouse): void + { + if (! $this->ready()) { + return; + } + DB::transaction(function () use ($actor, $item, $warehouse) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); + $planning = app(SupplyPlanning::class); + $policy = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first(); + $rule = $planning->latest('auto_rule')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first(); + $old = $planning->latest('auto_need')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first(); + $origins = []; + $unreserved = 0; + foreach (WarehouseRequest::query()->where('warehouse_id', $warehouse)->get() as $request) { + $p = app(WarehouseFulfilment::class)->progress($request); + if (! in_array($p['state'], ['submitted', 'authorized', 'partial'], true)) { + continue; + } + foreach ($request->snapshot['lines'] as $index => $line) { + if ($line['item']['id'] === $item && $p['remaining'][$index] > 0) { + $unreserved += max(0, $p['remaining'][$index] - $p['reserved'][$index]); + $origins[] = ['warehouse_request_id' => $request->id, 'version' => $p['version'], 'line_index' => $index, 'destination' => $line['destination'], 'equipment' => $line['equipment'], 'remaining_milli' => $p['remaining'][$index], 'reserved_milli' => $p['reserved'][$index]]; + } + } + } + if ($policy === null && $unreserved === 0 && $old === null) { + return; + } + $purchases = $old?->data['purchases'] ?? []; + $snapshot = ['item' => ['id' => $item], 'warehouse' => ['id' => $warehouse], 'origins' => $origins, 'rule_id' => $rule?->id]; + $state = 'pending'; + $message = 'Parametrize requisitante, centro de custo e reposição por artigo/armazém.'; + $values = ['purchases' => $purchases, 'message' => $message]; + try { + $free = app(WarehouseReservations::class)->free($item, $warehouse); + $minimum = $policy?->data['minimum_stock_milli'] ?? 0; + $required = $unreserved > max(0, $free) || $free <= $minimum; + $snapshot += ['free_milli' => $free, 'unreserved_milli' => $unreserved, 'minimum_milli' => $minimum]; + if (! $required) { + $state = 'satisfied'; + $values['message'] = 'Disponibilidade acima do mínimo e sem falta no atendimento.'; + } else { + $this->check($rule !== null, $message); + $requester = $this->requester($actor, $rule); + $basis = $this->basis($rule); + $coverage = 0; + $blocked = false; + foreach ($purchases as $purchase) { + $r = Requisition::query()->find($purchase['id']); + $this->check($r !== null, 'A requisição de reposição anterior requer reconciliação.'); + if ($r->status === 'rejected') { + if ($purchase['rule_id'] === $rule->id) { + $blocked = true; + } + +continue; + } + $this->check(in_array($r->status, ['draft', 'pending', 'approved'], true), 'Rever o estado da reposição anterior antes de gerar nova compra.'); + $r->load('items'); + $line = $r->items->sole(); + $this->check($line->catalogue_entry_id === $basis['catalogue']['id'] && ($line->catalogue_snapshot['version'] ?? null) === $basis['catalogue']['version'], 'O rascunho automático anterior mudou de artigo; reconcilie a necessidade.'); + $received = 0; + foreach (DB::table('goods_receipt_items as gi')->join('goods_receipts as g', 'g.id', '=', 'gi.goods_receipt_id')->join('purchase_order_items as oi', 'oi.id', '=', 'gi.purchase_order_item_id')->where('gi.tenant_id', $actor->tenant_id)->where('oi.requisition_item_id', $line->id)->where('g.warehouse_id', $warehouse)->where('gi.stock_item_id', $item)->get(['gi.id', 'gi.accepted_milli']) as $receipt) { + $voided = collect(app(InventoryReversals::class)->corrections('receipt'))->contains(fn ($correction) => ($correction->snapshot['source']['receipt']['id'] ?? null) === $receipt->id); + if (! $voided) { + $received += (int) $receipt->accepted_milli; + } + } + $coverage += max(0, ReceiptWorkflow::milli($line->quantity) - $received); + } + $this->check(! $blocked, 'A reposição foi rejeitada. Rever e activar nova versão da parametrização antes de tentar novamente.'); + // A confirmed outside delivery also covers the need; never double-count an automatic order. + $automaticIds = array_column($purchases, 'id'); + foreach ($planning->latest('delivery')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'active')->get() as $delivery) { + $order = DB::table('purchase_orders')->where('id', $delivery->purchase_order_id)->first(); + if ($order !== null && ! in_array($order->requisition_id, $automaticIds, true)) { + $coverage += app(SupplyCoordination::class)->remainingDelivery($delivery); + } + } + $demand = max(0, $basis['policy']['target_milli'] - $free + $unreserved - $coverage); + $snapshot += ['coverage_milli' => $coverage, 'policy' => $basis['policy'], 'catalogue' => $basis['catalogue'], 'requester' => $requester->only(['id', 'name', 'department_id']), 'cost_center' => $rule->snapshot['cost_center']]; + if ($demand === 0) { + $state = 'covered'; + $values['message'] = 'Reposição efectiva já em curso; não foi duplicada.'; + } else { + $pack = $basis['policy']['pack_milli']; + $quantity = (int) (ceil(max($demand, $basis['policy']['minimum_milli']) / $pack) * $pack); + $this->check($quantity <= 1000000000 && $quantity + $free - $unreserved + $coverage <= $basis['policy']['maximum_milli'], 'A embalagem ou quantidade mínima excede o máximo aprovado. Rever a política.'); + $decimal = intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT); + $r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, ['cost_center_id' => $rule->data['cost_center_id'], 'priority' => 'normal', 'needed_by' => today()->addDays($rule->data['needed_days'])->toDateString(), + 'justification' => 'Reposição automática do armazém '.$basis['warehouse']['code'].', artigo '.$basis['item']['code'].'. Origem e utilização preservadas na necessidade de reposição.', + 'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]]); + $values['purchases'][] = ['id' => $r->id, 'number' => $r->number, 'quantity_milli' => $quantity, 'rule_id' => $rule->id]; + $state = 'created'; + $values['message'] = 'Rascunho criado; o requisitante deve conferir e submeter às aprovações habituais.'; + } + } + } catch (ValidationException $error) { + $values['message'] = implode(' ', array_merge(...array_values($error->errors()))); + } catch (ModelNotFoundException $error) { + $values['message'] = 'Uma origem configurada deixou de estar disponível. Reveja a parametrização.'; + } + if ($old !== null && $old->state === $state && $old->data === $values && $old->snapshot === $snapshot) { + return; + } + $this->append($actor, 'auto_need', $old, $state, $values, $snapshot, 'Avaliação automática após alteração da disponibilidade ou parametrização.'); + }); + } + + private function append(User $actor, string $kind, ?SupplyRecord $old, string $state, array $data, array $snapshot, string $reason, ?string $key = null, ?string $hash = null): SupplyRecord + { + $row = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old?->record_key ?? (string) Str::uuid(), 'version' => ($old?->version ?? 0) + 1, 'state' => $state, + 'stock_item_id' => $snapshot['item']['id'], 'warehouse_id' => $snapshot['warehouse']['id'], 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason, + 'operation_key' => $key ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode([$state, $data, $snapshot], JSON_THROW_ON_ERROR)), 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor,$kind.'.'.$state,null,null,['record_id' => $row->id, 'record_key' => $row->record_key, 'version' => $row->version, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason]); + + return $row; + } + + private function check(bool $valid,string $message): void + { + if (! $valid) { + throw ValidationException::withMessages(['replenishment' => $message]); + } + } +} diff --git a/app/Services/InternalRequisitionPort.php b/app/Services/InternalRequisitionPort.php new file mode 100644 index 0000000..bf98fd7 --- /dev/null +++ b/app/Services/InternalRequisitionPort.php @@ -0,0 +1,176 @@ +,?Requisition):Requisition $save */ + public function __construct(private User $actor, private \Closure $save, private ?SupplyRecord $automaticRule = null) {} + + private function model(int $id): Requisition + { + return Requisition::query()->whereKey($id)->firstOrFail(); + } + + public function actorId(): int + { + return $this->actor->id; + } + + public function atomic(callable $work): mixed + { + return DB::transaction(function () use ($work) { + app(ConflictGuard::class)->lockCompany($this->actor); + $this->actor = app(CurrentActor::class)->lock($this->actor); + abort_unless(app(OrganizationAccess::class)->permissions($this->actor) !== [], 403); + abort_if(app(OrganizationAccess::class)->maintenanceOnly($this->actor), 403); + if (request()->hasSession() && app(WorkContext::class)->enforced($this->actor)) { + abort_unless(app(WorkContext::class)->active(request()) === 'procurement.internal', 403); + } + + return $work(); + }); + } + + public function authorize(string $action, ?array $record): void + { + if ($this->automaticRule !== null) { + abort_unless($action === 'save' && $record === null, 403); + app(AutomaticPurchases::class)->requester($this->actor, $this->automaticRule); + + return; + } + if ($record !== null) { + $r = $this->model($record['id']); + if ($action === 'view') { + Requisition::query()->visibleTo($this->actor)->findOrFail($record['id']); + } + abort_unless(($r->work_context_key ?? 'procurement.internal') === 'procurement.internal', 403); + if ($action === 'decide' || str_starts_with($action, 'step:')) { + abort_if(app(ConflictGuard::class)->participationBlocked($this->actor, $r), 403); + } elseif ($action !== 'view') { + app(ConflictGuard::class)->assertParticipation($this->actor, $r); + } + } + if (in_array($action, ['save', 'submit'], true)) { + abort_unless(app(OrganizationAccess::class)->canRequest($this->actor), 403); + if ($record !== null) { + abort_unless($record['requester_id'] === $this->actor->id, 403); + } + } elseif ($action === 'decide') { + abort_unless($this->actor->hasFunctions(['department_head', 'procurement', 'finance']), 403); + } elseif (str_starts_with($action, 'step:')) { + $role = substr($action, 5); + abort_unless($this->actor->hasFunctions([$role]) && ($role !== 'department_head' || $this->actor->department_id === $record['department_id']), 403); + } + } + + public function load(int $id): array + { + $r = Requisition::query()->lockForUpdate()->findOrFail($id); + + return $this->snapshot($r); + } + + /** @return array */ + private function snapshot(Requisition $r): array + { + $r->load(['steps' => fn ($q) => $q->orderBy('position'), 'items']); + + return $r->toArray() + ['version' => (int) ($r->getAttribute('process_version') ?? 1), 'has_equipment' => $r->items->contains('purchase_type', 'equipment')]; + } + + public function draft(array $data, ?int $id): array + { + return $this->snapshot(($this->save)($data, $id === null ? null : $this->model($id))); + } + + public function policy(array $record): array + { + $r = $this->model($record['id']); + if ($this->actor->department_id !== $r->department_id) { + throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']); + } + foreach ($r->items()->get() as $item) { + app(EquipmentWorkflow::class)->line($item->toArray(), $r->department_id, $r->priority); + } + app(EquipmentWorkflow::class)->assertTechnical($r); + if ($r->needed_by < today()) { + throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']); + } + + return Tenant::query()->findOrFail($this->actor->tenant_id)->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']); + } + + public function reserve(array $record): ?array + { + return app(BudgetWorkflow::class)->record($this->actor, $this->model($record['id']), 'reserve', $record['total_minor'], $record['subtotal_minor']); + } + + public function submitted(array $record, array $policy, array $roles): array + { + $r = $this->model($record['id']); + foreach ($roles as $position => $role) { + $r->steps()->create(['tenant_id' => $this->actor->tenant_id, 'position' => $position + 1, 'role' => $role]); + } + $values = ['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $policy]; + if (Schema::hasColumn('requisitions', 'process_version')) { + $values['process_version'] = $record['version'] + 1; + } + $r->update($values); + app(RequisitionWorkflow::class)->audit($this->actor, 'requisition.submitted', $r, ['status' => 'draft'], ['status' => 'pending', 'rules' => $policy, 'roles' => $roles]); + app(WorkflowNotices::class)->capture($r, $this->actor); + + return $this->snapshot($r); + } + + public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array + { + $r = $this->model($record['id']); + if ($decision === 'approved') { + app(EquipmentWorkflow::class)->assertTechnical($r); + } + $r->steps()->whereKey($step['id'])->firstOrFail()->update(['decision' => $decision, 'approver_id' => $this->actor->id, 'comment' => $comment, 'decided_at' => now()]); + $values = ['status' => $status]; + if (Schema::hasColumn('requisitions', 'process_version')) { + $values['process_version'] = $record['version'] + 1; + } + $r->update($values); + app(RequisitionWorkflow::class)->audit($this->actor, 'approval.'.$decision, $r, ['status' => 'pending'], ['status' => $status, 'step_id' => $step['id'], 'role' => $step['role'], 'comment' => $comment]); + app(WorkflowNotices::class)->capture($r, $this->actor); + app(WorkflowNotices::class)->outcome($r, $step['id']); + + return $this->snapshot($r); + } + + public function release(array $record): void + { + app(BudgetWorkflow::class)->record($this->actor, $this->model($record['id']), 'release'); + } + + public function replay(string $key, string $hash): ?array + { + abort_unless(Schema::hasTable('requisition_commands'), 503, 'Instale a migração de recibos antes de usar chaves de operação.'); + $old = DB::table('requisition_commands')->where(['tenant_id' => $this->actor->tenant_id, 'actor_id' => $this->actor->id, 'operation_key' => $key])->first(); + if ($old === null) { + return null; + } + abort_unless(hash_equals($old->request_hash, $hash), 409, 'Chave usada com outro conteúdo.'); + + return json_decode($old->result, true, 64, JSON_THROW_ON_ERROR); + } + + public function remember(string $key, string $hash, array $result): void + { + DB::table('requisition_commands')->insert(['tenant_id' => $this->actor->tenant_id, 'actor_id' => $this->actor->id, 'operation_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]); + } +} diff --git a/app/Services/InventoryCounts.php b/app/Services/InventoryCounts.php new file mode 100644 index 0000000..82550e6 --- /dev/null +++ b/app/Services/InventoryCounts.php @@ -0,0 +1,101 @@ +>,setup_id:int}|null */ + public function source(int $item, int $warehouse): ?array + { + $context = app(InventoryLots::class)->context($item, $warehouse); + if ($context === null) { + return null; + } + $this->check($context['reconciled'], 'lot_counts', 'Confira as origens: a repartição não corresponde ao saldo físico.'); + $lots = array_map(fn ($lot) => ['lot_id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'quantity_milli' => $lot['quantity_milli'], + 'code' => $lot['data']['code'], 'expires_on' => $lot['data']['expires_on'], 'no_expiry' => $lot['data']['no_expiry']], $context['lots']); + $basis = ['setup_id' => $context['setup_id'], 'lots' => $lots]; + + return [...$basis, 'hash' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))]; + } + + /** @param array $data + * @return array|null + */ + public function prepare(int $item, int $warehouse, int $total, array $data): ?array + { + $source = $this->source($item, $warehouse); + if ($source === null) { + $this->check(empty($data['lot_counts']), 'lot_counts', 'Confira e active o controlo antes de contar por lote.'); + + return null; + } + $this->check(($data['lot_version'] ?? '') === $source['hash'], 'lot_version', 'Os lotes ou a identificação mudaram. Actualize e volte a conferir a contagem.'); + $input = Validator::make($data, ['lot_counts' => 'present|array|max:500', 'lot_counts.*.lot_id' => 'nullable|integer|min:1', + 'lot_counts.*.new_key' => 'nullable|uuid', 'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], + 'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean'])->validate(); + $rows = []; + $seen = []; + $known = collect($source['lots'])->keyBy('lot_id'); + foreach ($input['lot_counts'] as $entry) { + $quantity = ReceiptWorkflow::milli($entry['quantity']); + if (! empty($entry['lot_id'])) { + $id = (int) $entry['lot_id']; + $this->check($known->has($id) && ! isset($seen['lot-'.$id]) && empty($entry['new_key']), 'lot_counts', 'Seleccione uma única vez cada lote deste artigo/local.'); + $seen['lot-'.$id] = true; + $old = $known->get($id); + $rows[] = ['lot_id' => $id, 'metadata_id' => $old['metadata_id'], 'code' => $old['code'], 'expires_on' => $old['expires_on'], 'no_expiry' => $old['no_expiry'], + 'previous_milli' => $old['quantity_milli'], 'counted_milli' => $quantity, 'delta_milli' => $quantity - $old['quantity_milli']]; + } else { + $key = $entry['new_key'] ?? ''; + $this->check($key !== '' && ! isset($seen['new-'.$key]) && $quantity > 0, 'lot_counts', 'Identifique cada novo lote encontrado com quantidade positiva, sem duplicar a linha.'); + $this->check(empty($entry['expires_on']) || ! ($entry['no_expiry'] ?? false), 'lot_counts', 'Escolha prazo de validade ou confirmação de artigo sem prazo.'); + $seen['new-'.$key] = true; + $rows[] = ['lot_id' => null, 'new_key' => $key, 'metadata_id' => null, 'code' => empty(trim($entry['code'] ?? '')) ? null : trim($entry['code']), + 'expires_on' => $entry['expires_on'] ?? null, 'no_expiry' => (bool) ($entry['no_expiry'] ?? false), 'previous_milli' => 0, 'counted_milli' => $quantity, 'delta_milli' => $quantity]; + } + } + foreach ($source['lots'] as $lot) { + $this->check($lot['quantity_milli'] === 0 || isset($seen['lot-'.$lot['lot_id']]), 'lot_counts', 'Indique a contagem de todos os lotes com saldo, incluindo zero quando não encontrou quantidade.'); + } + $this->check(array_sum(array_column($rows, 'counted_milli')) === $total, 'quantity', 'O total deve corresponder exactamente às quantidades contadas por lote.'); + + return ['source_hash' => $source['hash'], 'setup_id' => $source['setup_id'], 'rows' => $rows, + 'increase_milli' => array_sum(array_map(fn ($r) => max(0, $r['delta_milli']), $rows)), 'decrease_milli' => array_sum(array_map(fn ($r) => max(0, -$r['delta_milli']), $rows))]; + } + + /** @return array */ + public function adjustment(User $actor, StockOperation $count): array + { + abort_unless(app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 403); + $this->check(! in_array($actor->id, [$count->created_by, $count->responsible_id], true), 'count_id', 'A conferência do ajuste exige pessoa diferente de quem registou e assumiu a contagem.'); + $plan = $count->snapshot['lot_count'] ?? null; + $this->check($plan !== null, 'count_id', 'Esta contagem não preservou repartição por lote. Faça nova contagem.'); + $source = $this->source($count->stock_item_id, $count->warehouse_id); + $this->check($source !== null && $plan['source_hash'] === $source['hash'], 'count_id', 'A repartição ou identificação mudou desde a contagem. Faça nova conferência física.'); + $this->check($plan['increase_milli'] + $plan['decrease_milli'] > 0, 'count_id', 'Contagem sem divergências de quantidade ou repartição.'); + foreach ($plan['rows'] as &$row) { + if ($row['lot_id'] === null) { + $lot = app(InventoryLots::class)->found($actor, $count, $row); + $row['lot_id'] = $lot->id; + $row['metadata_id'] = $lot->id; + } + } + unset($row); + + return $plan; + } + + private function check(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/InventoryDispositions.php b/app/Services/InventoryDispositions.php new file mode 100644 index 0000000..a6d613e --- /dev/null +++ b/app/Services/InventoryDispositions.php @@ -0,0 +1,204 @@ +maintenanceOnly($actor) && $actor->hasFunctions(match ($capability) { + 'read' => ['inventory_disposition_read', 'admin', PurchasingRole::Warehouse], 'execute' => [PurchasingRole::Warehouse], + default => ['inventory_disposition_'.$capability], + }); + } + + public function returnedForReceipt(int $receiptItem): int + { + if (! Schema::hasTable('supply_records')) { + return 0; + } + + $reversed = app(InventoryReversals::class)->corrections('operation'); + + return (int) app(SupplyPlanning::class)->latest('stock_event')->where('state', 'executed')->whereJsonContains('data->kind', 'supplier_return') + ->whereJsonContains('data->receipt_item_id', $receiptItem)->get()->reject(fn ($r) => isset($reversed[$r->data['stock_operation_id']]))->sum(fn ($r) => $r->data['quantity_milli']); + } + + public function returnedForOrderLine(int $line): int + { + if (! Schema::hasTable('supply_records')) { + return 0; + } + + $reversed = app(InventoryReversals::class)->corrections('operation'); + + return (int) app(SupplyPlanning::class)->latest('stock_event')->where('state', 'executed')->whereJsonContains('data->kind', 'supplier_return') + ->whereJsonContains('data->purchase_order_item_id', $line)->get()->reject(fn ($r) => isset($reversed[$r->data['stock_operation_id']]))->sum(fn ($r) => $r->data['quantity_milli']); + } + + /** Minimal receipt facts for the existing Finance reconciliation screen. + * @return list> + */ + public function financialNotices(): array + { + if (! Schema::hasTable('supply_records')) { + return []; + } + + return array_values(app(SupplyPlanning::class)->latest('stock_event')->where('state', 'executed')->whereJsonContains('data->kind', 'supplier_return')->latest('id')->limit(100)->get() + ->map(fn ($record) => ['reference' => $record->data['reference'], 'quantity_milli' => $record->data['quantity_milli'], 'unit' => $record->snapshot['source']['item']['unit'], + 'order_number' => $record->snapshot['source']['receipt']['order_number'], 'supplier_name' => $record->snapshot['source']['receipt']['supplier_name'], 'event_on' => $record->data['event']['event_on'], 'reversed' => app(InventoryReversals::class)->operationReversed($record->data['stock_operation_id'])])->all()); + } + + /** @param array $data + * @return array{source:array,observed:array} + */ + public function basis(array $data): array + { + $item = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail(); + $warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail(); + $balance = app(StockWorkflow::class)->balance($item->id, $warehouse->id); + $this->check($data['quantity_milli'] > 0 && $data['quantity_milli'] <= $balance['quantity'], 'data.quantity', 'A quantidade deve ser positiva e existir fisicamente neste local.'); + $source = ['item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'setup_id' => null, 'lot' => null, 'receipt' => null]; + $observed = ['balance' => $balance]; + $context = app(InventoryLots::class)->context($item->id, $warehouse->id); + if ($context !== null) { + $this->check($context['reconciled'], 'data.lot_id', 'Confira a repartição por lote antes de retirar material.'); + $lot = collect($context['lots'])->firstWhere('id', $data['lot_id'] ?? 0); + $this->check($lot !== null && $lot['quantity_milli'] >= $data['quantity_milli'], 'data.lot_id', 'Seleccione o lote exacto deste local com quantidade física suficiente.'); + $source['setup_id'] = $context['setup_id']; + $source['lot'] = ['id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'code' => $lot['data']['code'], 'expires_on' => $lot['data']['expires_on'], + 'no_expiry' => $lot['data']['no_expiry'], 'origin' => $lot['origin']]; + $observed['lot_quantity_milli'] = $lot['quantity_milli']; + } else { + $this->check(empty($data['lot_id']), 'data.lot_id', 'Não associe um lote sem controlo activo e conferido neste local.'); + } + if ($data['kind'] === 'supplier_return') { + $line = GoodsReceiptItem::query()->whereKey($data['receipt_item_id'])->firstOrFail(); + $this->check(! app(InventoryReversals::class)->receiptReversed($line->id), 'data.receipt_item_id', 'Esta linha foi estornada. Seleccione a recepção efectiva de origem.'); + $receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail(); + $order = PurchaseOrder::query()->whereKey($receipt->purchase_order_id)->firstOrFail(); + $this->check($line->stock_item_id === $item->id && $line->accepted_milli > 0, 'data.receipt_item_id', 'Seleccione uma recepção física aceite deste artigo.'); + $originalUnit = $line->snapshot['stock_item']['unit'] ?? null; + $this->check(is_string($originalUnit) && Str::lower(trim($originalUnit)) === Str::lower(trim($item->unit)), 'data.receipt_item_id', 'A unidade mudou ou falta na origem. Reconcilie antes de devolver; não há conversão automática.'); + if ($context === null) { + $this->check($receipt->warehouse_id === $warehouse->id, 'data.warehouse_id', 'Sem lotes rastreados, a devolução usa o local da recepção original.'); + } else { + $this->check(($source['lot']['origin']['receipt_item_id'] ?? null) === $line->id, 'data.receipt_item_id', 'O lote deve ter origem comprovada nesta linha de recepção; igualdade de código não prova a origem.'); + } + $returned = $this->returnedForReceipt($line->id); + $this->check($returned + $data['quantity_milli'] <= $line->accepted_milli, 'data.quantity', 'A soma das devoluções não pode exceder a quantidade originalmente aceite.'); + $source['receipt'] = ['id' => $receipt->id, 'number' => $receipt->number, 'item_id' => $line->id, 'purchase_order_item_id' => $line->purchase_order_item_id, + 'purchase_order_id' => $order->id, 'order_number' => $order->number, 'supplier_id' => $order->supplier_id, 'supplier_name' => $receipt->snapshot['supplier']['name'], 'accepted_milli' => $line->accepted_milli]; + $observed['previously_returned_milli'] = $returned; + } + + return ['source' => $source, 'observed' => $observed]; + } + + /** @param array $input */ + public function save(User $actor, array $input): SupplyRecord + { + return DB::transaction(function () use ($actor, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', + 'action' => 'required|in:save,approve,return,execute', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + $capability = match ($v['action']) { + 'save' => 'prepare', 'execute' => 'execute', default => 'review' + }; + abort_unless($this->allows($actor, $capability), 403); + $hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR)); + $retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first(); + if ($retry !== null) { + $this->check($retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outros dados.'); + + return $retry; + } + $old = empty($v['record_key']) ? null : app(SupplyPlanning::class)->latest('stock_event')->where('record_key', $v['record_key'])->firstOrFail(); + $this->check((int) $v['version'] === ($old->version ?? 0), 'version', 'O processo mudou. Actualize a página.'); + if ($v['action'] === 'save') { + $this->check($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Preserve a decisão: devolva para actualização antes de alterar.'); + $data = Validator::make($v['data'], ['kind' => 'required|in:supplier_return,write_off', 'stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1', + 'lot_id' => 'nullable|integer|min:1', 'receipt_item_id' => 'required_if:kind,supplier_return|nullable|integer|min:1', 'reference' => 'required|string|max:100', + 'quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/']])->validate(); + foreach (['stock_item_id', 'warehouse_id', 'lot_id', 'receipt_item_id'] as $id) { + $data[$id] = empty($data[$id]) ? null : (int) $data[$id]; + } + if ($data['kind'] === 'write_off') { + $data['receipt_item_id'] = null; + } + $data['quantity_milli'] = ReceiptWorkflow::milli($data['quantity']); + unset($data['quantity']); + $data['reference'] = Str::upper(Str::squish($data['reference'])); + $this->check($data['reference'] !== '', 'data.reference', 'Indique a referência do documento de saída.'); + $clash = app(SupplyPlanning::class)->latest('stock_event')->whereJsonContains('data->reference', $data['reference']); + if ($old !== null) { + $clash->where('record_key', '!=', $old->record_key); + } + $this->check(! $clash->exists(), 'data.reference', 'Referência já utilizada noutro processo de stock.'); + $this->check(! StockOperation::query()->where('reference', $data['reference'])->exists(), 'data.reference', 'Referência já utilizada numa operação de stock.'); + $snapshot = $this->basis($data); + $snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])); + $data['purchase_order_item_id'] = $snapshot['source']['receipt']['purchase_order_item_id'] ?? null; + $state = 'draft'; + } else { + $this->check($old !== null, 'action', 'Prepare primeiro o processo.'); + $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.'); + $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); + $this->check($current['source'] === $snapshot['source'], 'action', 'A origem, identificação ou configuração mudou. Devolva para nova preparação.'); + $operation = app(StockWorkflow::class)->disposition($actor, $old); + $data['stock_operation_id'] = $operation->id; + $data['event'] = $event; + $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($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') { + $current = $this->basis($data); + $this->check($current['source'] === $snapshot['source'], 'action', 'A origem ou identificação mudou. Devolva para actualizar.'); + $snapshot['reviewed_by'] = $actor->id; + $snapshot['review_observed'] = $current['observed']; + } + } + } + $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'stock_event', 'record_key' => $old->record_key ?? (string) Str::uuid(), 'version' => ($old->version ?? 0) + 1, + 'state' => $state, 'stock_item_id' => $data['stock_item_id'], 'warehouse_id' => $data['warehouse_id'], 'created_by' => $actor->id, 'data' => $data, + 'supplier_id' => $snapshot['source']['receipt']['supplier_id'] ?? null, 'purchase_order_id' => $snapshot['source']['receipt']['purchase_order_id'] ?? null, + 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $v['reason'], 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'inventory.disposition.'.$v['action'], null, $old?->toArray(), $record->toArray()); + + return $record; + }); + } + + private function check(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/InventoryLots.php b/app/Services/InventoryLots.php new file mode 100644 index 0000000..caad559 --- /dev/null +++ b/app/Services/InventoryLots.php @@ -0,0 +1,453 @@ +hasFunctions($permission === 'inventory_lot_read' ? [$permission, 'admin'] : [$permission]) + && ! app(OrganizationAccess::class)->maintenanceOnly($actor); + } + + public function setup(int $item, int $warehouse): ?SupplyRecord + { + if (! Schema::hasTable('inventory_lot_allocations')) { + return null; + } + + return $this->activeSetups()->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first(); + } + + /** Active versions remain available while a proposed revision awaits review. + * @return Builder + */ + public function activeSetups(): Builder + { + return SupplyRecord::query()->where('kind', 'lot_setup')->where('state', 'active')->whereIn('id', SupplyRecord::query()->selectRaw('MAX(id)')->where('kind', 'lot_setup')->where('state', 'active')->groupBy('stock_item_id', 'warehouse_id')); + } + + /** @return array{setup_id:int,physical:array{quantity:int,version:int},reconciled:bool,usable_milli:int,blocked_milli:int,lots:list>}|null */ + public function context(int $item, int $warehouse): ?array + { + // Read book and partitions under the same lock used by every stock writer. + $reader = auth()->user(); + if (DB::transactionLevel() === 0 && $reader instanceof User) { + return DB::transaction(function () use ($item, $warehouse, $reader) { + app(ConflictGuard::class)->lockCompany($reader); + + return $this->context($item, $warehouse); + }); + } + $setup = $this->setup($item, $warehouse); + if ($setup === null) { + return null; + } + $physical = app(StockWorkflow::class)->balance($item, $warehouse); + $lots = []; + foreach (InventoryLotAllocation::query()->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->selectRaw('lot_id, SUM(quantity_milli) AS quantity')->groupBy('lot_id')->orderBy('lot_id')->get() as $allocation) { + $identity = SupplyRecord::query()->whereKey($allocation->lot_id)->firstOrFail(); + $lot = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); + $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $warehouse)->first(); + $lastDay = empty($lot->data['expires_on']) ? null : CarbonImmutable::parse($lot->data['expires_on'])->subDays($setup->data['minimum_remaining_days'])->toDateString(); + $identified = ! empty($lot->data['code']) && (! empty($lot->data['expires_on']) || (! $setup->data['requires_expiry'] && ($lot->data['no_expiry'] ?? false))); + $eligible = $identified && ($lastDay === null || $lastDay >= today()->toDateString()); + $released = $state?->state === 'released' && ($state->data['metadata_id'] ?? null) === $lot->id; + $quantity = (int) $allocation->getAttribute('quantity'); + $lots[] = ['id' => $identity->id, 'record_key' => $lot->record_key, 'version' => $lot->version, 'metadata_id' => $lot->id, 'data' => $lot->data, + 'quantity_milli' => $quantity, 'usable_milli' => $eligible && $released ? $quantity : 0, 'eligible' => $eligible, + 'state' => ! $identified ? 'unidentified' : (! $eligible ? 'expired' : ($released ? 'released' : 'held')), 'last_usable_on' => $lastDay, + 'quality_released' => $released, 'release_mode' => $state?->snapshot['release_mode'] ?? 'independent', 'release_setup_id' => $state?->snapshot['release_setup_id'] ?? null, + 'state_version' => $state->version ?? 0, 'contributors' => array_values(array_unique([...$lot->snapshot['contributors'], ...($state?->snapshot['pending_contributors'] ?? [])])), 'origin' => $identity->snapshot['origin'] ?? null]; + } + $total = array_sum(array_column($lots, 'quantity_milli')); + $usable = array_sum(array_column($lots, 'usable_milli')); + + return ['setup_id' => $setup->id, 'physical' => $physical, 'reconciled' => $total === $physical['quantity'], 'usable_milli' => $usable, + 'blocked_milli' => $total - $usable, 'lots' => $lots]; + } + + /** @param array $forecast + * @param array{quantity:int,version:int} $balance + * @param array $events + * @return array + */ + public function project(SupplyRecord $policy, array $forecast, array $balance, array $events, string $date): array + { + $context = $this->context($policy->stock_item_id, $policy->warehouse_id); + if ($context === null) { + return app(ReplenishmentProjection::class)->calculate($forecast, $balance['quantity'], $date, $events['reservations'], $events['receipts'], $events['consumed_today'], $events['fulfilled_needs']); + } + if (! $context['reconciled']) { + return ['available' => false, 'reason' => 'Confira a repartição por lotes: não corresponde ao saldo físico.', 'lot_context' => $context]; + } + $buckets = array_map(fn ($l) => ['quantity' => $l['usable_milli'], 'last_usable_on' => $l['last_usable_on']], $context['lots']); + usort($buckets, fn ($a, $b) => ($a['last_usable_on'] ?? '9999-12-31') <=> ($b['last_usable_on'] ?? '9999-12-31')); + $projection = app(ReplenishmentProjection::class)->calculate($forecast, $context['usable_milli'], $date, $events['reservations'], [], $events['consumed_today'], $events['fulfilled_needs'], $buckets); + + return [...$projection, 'physical_milli' => $context['physical']['quantity'], 'usable_milli' => $context['usable_milli'], 'lot_context' => $context, + 'lot_warning' => 'Entradas futuras sem identificação e libertação de lote não são consideradas utilizáveis nesta previsão.']; + } + + /** @return array{usable_milli:int,blocked_milli:int,reconciled:bool}|null */ + public function summary(int $item, int $warehouse): ?array + { + $context = $this->context($item, $warehouse); + + return $context === null ? null : ['usable_milli' => $context['usable_milli'], 'blocked_milli' => $context['blocked_milli'], 'reconciled' => $context['reconciled']]; + } + + /** @param array $input */ + public function save(User $actor, array $input): SupplyRecord + { + return DB::transaction(function () use ($actor, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', + 'action' => 'required|in:prepare,activate,identify,hold,release', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + $permission = match ($v['action']) { + 'activate' => 'inventory_lot_review', 'hold' => 'inventory_lot_hold', 'release' => 'inventory_lot_release', default => 'inventory_lot_prepare', + }; + abort_unless($this->allows($actor, $permission), 403); + $hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR)); + $retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first(); + if ($retry) { + $this->check($retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outros dados.'); + + return $retry; + } + $old = empty($v['record_key']) ? null : SupplyRecord::query()->where('record_key', $v['record_key'])->latest('version')->firstOrFail(); + $this->check((int) $v['version'] === ($old->version ?? 0), 'version', 'O registo mudou. Actualize a página.'); + if ($v['action'] === 'prepare') { + $this->check($old === null || ($old->kind === 'lot_setup' && in_array($old->state, ['draft', 'active'], true)), 'action', 'Seleccione a configuração deste artigo/local.'); + $d = Validator::make($v['data'], ['stock_item_id' => 'required|integer', 'warehouse_id' => 'required|integer', 'requires_expiry' => 'required|boolean', + 'minimum_remaining_days' => 'required|integer|min:0|max:3650', 'quarantine_on_receipt' => 'sometimes|boolean', 'lots' => 'present|array|max:100'])->validate(); + StockItem::query()->findOrFail($d['stock_item_id']); + Warehouse::query()->findOrFail($d['warehouse_id']); + $active = $this->setup((int) $d['stock_item_id'], (int) $d['warehouse_id']); + $this->check($active === null || ($old !== null && $old->record_key === $active->record_key), 'action', 'Actualize por referência à configuração activa; não crie um segundo controlo.'); + $this->check($old?->state !== 'active' || ($old->stock_item_id === (int) $d['stock_item_id'] && $old->warehouse_id === (int) $d['warehouse_id']), 'data', 'Uma revisão conserva o artigo/local da origem.'); + $this->check($old?->state !== 'draft' || empty($old->snapshot['previous_setup_id']) || ($active !== null && $active->id === $old->snapshot['previous_setup_id']), 'action', 'A configuração de origem mudou. Prepare nova revisão.'); + $balance = app(StockWorkflow::class)->balance((int) $d['stock_item_id'], (int) $d['warehouse_id']); + $d['stock_item_id'] = (int) $d['stock_item_id']; + $d['warehouse_id'] = (int) $d['warehouse_id']; + $d['requires_expiry'] = (bool) $d['requires_expiry']; + $d['minimum_remaining_days'] = (int) $d['minimum_remaining_days']; + $d['quarantine_on_receipt'] = (bool) ($d['quarantine_on_receipt'] ?? true); + if ($active !== null) { + $this->check($d['lots'] === [], 'data.lots', 'Uma revisão altera parâmetros; não volta a repartir ou criar saldo inicial.'); + } else { + $d['lots'] = $this->split($d['lots'], $balance['quantity']); + } + $context = $active === null ? null : $this->context($active->stock_item_id, $active->warehouse_id); + $this->check($context === null || $context['reconciled'], 'action', 'Reconcilie o saldo antes de alterar parâmetros.'); + $snapshot = ['balance' => $balance, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])), + 'previous_setup_id' => $active?->id, 'lot_stamp' => $context === null ? null : hash('sha256', json_encode($context, JSON_THROW_ON_ERROR)), + 'impact' => $context === null ? null : $this->impact($context, $d), + 'item' => StockItem::query()->findOrFail($d['stock_item_id'])->only(['id', 'code', 'name', 'unit']), 'warehouse' => Warehouse::query()->findOrFail($d['warehouse_id'])->only(['id', 'code', 'name'])]; + + return $this->append($actor, 'lot_setup', 'draft', $d, $snapshot, $v['reason'], $old, $v['operation_key'], $hash); + } + 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.'); + $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.'); + if ($active !== null) { + $context = $this->context($old->stock_item_id, $old->warehouse_id); + $this->check(hash('sha256', json_encode($context, JSON_THROW_ON_ERROR)) === $old->snapshot['lot_stamp'], 'action', 'A identificação, qualidade ou disponibilidade mudou. Actualize a previsão de impacto.'); + } + $active = $this->append($actor, 'lot_setup', 'active', $old->data, $old->snapshot, $v['reason'], $old, $v['operation_key'], $hash); + foreach ($old->data['lots'] as $entry) { + $lot = $this->append($actor, 'lot', 'registered', [...$entry, 'stock_item_id' => $old->stock_item_id, 'warehouse_id' => $old->warehouse_id], + ['contributors' => $old->snapshot['contributors'], 'origin' => ['setup_id' => $active->id]], 'Identificação inicial do saldo conferido.'); + $this->allocate($lot->id, $old->warehouse_id, $entry['quantity_milli'], null, $active); + } + + return $active; + } + if ($v['action'] === 'identify') { + $this->check($old?->kind === 'lot', 'action', 'Seleccione a identificação existente.'); + $metadata = $this->metadata($v['data']); + + // A correction invalidates the previous quality release by metadata version. + return $this->append($actor, 'lot', 'registered', [...$old->data, ...$metadata], + [...$old->snapshot, 'contributors' => array_values(array_unique([...$old->snapshot['contributors'], $actor->id]))], $v['reason'], $old, $v['operation_key'], $hash); + } + $d = Validator::make($v['data'], ['lot_id' => 'required|integer', 'warehouse_id' => 'required|integer', 'state_version' => 'required|integer|min:0'])->validate(); + $identity = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->whereKey($d['lot_id'])->firstOrFail(); + $context = $this->context($identity->stock_item_id, (int) $d['warehouse_id']); + $entry = collect($context['lots'] ?? [])->firstWhere('id', $identity->id); + $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.'); + 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.'); + } + + return $this->append($actor, 'lot_state', $v['action'] === 'release' ? 'released' : 'held', ['lot_id' => $identity->id, 'metadata_id' => $old->id, + 'stock_item_id' => $identity->stock_item_id, 'warehouse_id' => (int) $d['warehouse_id']], + ['pending_contributors' => $v['action'] === 'release' ? [] : ($state?->snapshot['pending_contributors'] ?? [])], $v['reason'], $state, $v['operation_key'], $hash); + }); + } + + /** @param array $input + * @return array{code:?string,expires_on:?string,no_expiry:bool} + */ + private function metadata(array $input): array + { + $d = Validator::make($input, ['code' => 'nullable|string|max:100', 'expires_on' => 'nullable|date_format:Y-m-d', 'no_expiry' => 'sometimes|boolean'])->validate(); + $this->check(empty($d['expires_on']) || ! ($d['no_expiry'] ?? false), 'data', 'Escolha validade ou confirmação de artigo sem prazo.'); + + return ['code' => empty(trim($d['code'] ?? '')) ? null : trim($d['code']), 'expires_on' => $d['expires_on'] ?? null, 'no_expiry' => (bool) ($d['no_expiry'] ?? false)]; + } + + /** @param array $context + * @param array $data + * @return array{before_milli:int,after_milli:int} + */ + private function impact(array $context, array $data): array + { + $after = 0; + foreach ($context['lots'] as $lot) { + $identified = ! empty($lot['data']['code']) && (! empty($lot['data']['expires_on']) || (! $data['requires_expiry'] && $lot['data']['no_expiry'])); + $last = empty($lot['data']['expires_on']) ? null : CarbonImmutable::parse($lot['data']['expires_on'])->subDays($data['minimum_remaining_days'])->toDateString(); + if ($lot['quality_released'] && $identified && ($last === null || $last >= today()->toDateString())) { + $after += $lot['quantity_milli']; + } + } + + return ['before_milli' => $context['usable_milli'], 'after_milli' => $after]; + } + + /** @param list> $entries + * @return list> + */ + public function split(array $entries, int $quantity): array + { + if ($quantity === 0) { + $this->check($entries === [], 'data.lots', 'Saldo nulo não recebe lotes iniciais.'); + + return []; + } + if ($entries === []) { + $entries = [[]]; // Unknown identity is quarantined, never treated as unlimited validity. + } + $result = []; + foreach ($entries as $entry) { + $v = Validator::make($entry, ['quantity' => ['nullable', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/']])->validate(); + $q = count($entries) === 1 && empty($v['quantity']) ? $quantity : ReceiptWorkflow::milli($v['quantity'] ?? '0'); + $this->check($q > 0, 'data.lots', 'Indique a quantidade positiva de cada lote quando existe repartição.'); + $result[] = [...$this->metadata($entry), 'quantity_milli' => $q]; + } + $this->check(array_sum(array_column($result, 'quantity_milli')) === $quantity, 'data.lots', 'A soma dos lotes deve corresponder exactamente à quantidade física.'); + + return $result; + } + + /** @param list> $entries */ + public function receive(User $actor, StockMovement $movement, array $entries, string $receivedOn): void + { + $setup = $this->setup($movement->stock_item_id, $movement->warehouse_id); + if ($setup === null) { + $this->check($entries === [], 'items', 'Active e confira o controlo por lotes antes de registar a repartição.'); + + return; + } + foreach ($this->split($entries, $movement->quantity_milli) as $entry) { + $this->check(empty($entry['expires_on']) || $entry['expires_on'] >= $receivedOn, 'items', 'Um lote já vencido na data da recepção não pode ser aceite.'); + $lot = $this->append($actor, 'lot', 'registered', [...$entry, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], + ['contributors' => [$actor->id], 'origin' => ['movement_id' => $movement->id, 'receipt_item_id' => $movement->goods_receipt_item_id, 'receipt_setup_id' => $setup->id]], 'Identificação da recepção sob a configuração aprovada; disponibilidade determinada pela regra e qualidade.'); + $this->allocate($lot->id, $movement->warehouse_id, $entry['quantity_milli'], $movement); + $quality = GoodsReceiptItem::query()->whereKey($movement->goods_receipt_item_id)->firstOrFail(); + $identified = ! empty($entry['code']) && (! empty($entry['expires_on']) || (! $setup->data['requires_expiry'] && $entry['no_expiry'])); + $valid = empty($entry['expires_on']) || CarbonImmutable::parse($entry['expires_on'])->subDays($setup->data['minimum_remaining_days'])->toDateString() >= today()->toDateString(); + if (! ($setup->data['quarantine_on_receipt'] ?? true) && $quality->quality_status === 'compliant' && $identified && $valid) { + $this->append($actor, 'lot_state', 'released', ['lot_id' => $lot->id, 'metadata_id' => $lot->id, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], + ['release_mode' => 'receipt_policy', 'release_setup_id' => $setup->id, 'pending_contributors' => []], 'Dispensa de quarentena de recepção conforme, por regra previamente aprovada.'); + } + } + $this->check($this->context($movement->stock_item_id, $movement->warehouse_id)['reconciled'], 'items', 'Existe divergência na repartição anterior do stock. Confira as origens antes de receber.'); + } + + /** @return list */ + public function issuePlan(int $item, int $warehouse, int $quantity): array + { + $context = $this->context($item, $warehouse); + if ($context === null) { + return []; + } + $this->check($context['reconciled'], 'quantity', 'Repartição por lotes divergente do saldo físico. Suspenda operações e confira as origens.'); + $this->check($quantity <= $context['usable_milli'], 'quantity', 'Quantidade utilizável insuficiente: lotes em quarentena, sem identificação ou sem validade suficiente não podem sair.'); + $lots = $context['lots']; + usort($lots, fn ($a, $b) => [$a['last_usable_on'] ?? '9999-12-31', $a['id']] <=> [$b['last_usable_on'] ?? '9999-12-31', $b['id']]); + $plan = []; + foreach ($lots as $lot) { + $take = min($quantity, $lot['usable_milli']); + if ($take > 0) { + $plan[] = ['lot_id' => $lot['id'], 'quantity' => $take]; + $quantity -= $take; + } + } + + return $plan; + } + + public function allocate(int $lotId, int $warehouse, int $quantity, ?StockMovement $movement = null, ?SupplyRecord $setup = null): void + { + $lot = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->findOrFail($lotId); + InventoryLotAllocation::query()->create(['tenant_id' => $lot->tenant_id, 'stock_item_id' => $lot->stock_item_id, 'warehouse_id' => $warehouse, + 'lot_id' => $lot->id, 'stock_movement_id' => $movement?->id, 'setup_record_id' => $setup?->id, 'quantity_milli' => $quantity, 'created_at' => now()]); + } + + /** @return list */ + public function transferPlan(int $item, int $source, int $destination, int $quantity): array + { + $from = $this->setup($item, $source); + $to = $this->setup($item, $destination); + if ($from === null && $to === null) { + return []; + } + $this->check($quantity > 0, 'quantity', 'A transferência exige quantidade positiva.'); + $this->check($from !== null && $to !== null, 'destination_warehouse_id', 'Confira e active o controlo por lotes deste artigo nos dois armazéns antes de transferir.'); + $context = $this->context($item, $destination); + $this->check($context !== null && $context['reconciled'], 'destination_warehouse_id', 'A repartição dos lotes no destino deve corresponder ao saldo físico.'); + + return $this->issuePlan($item, $source, $quantity); + } + + /** Reuses identities; destination quality is independent of custody. + * @param list $plan + */ + public function transferred(User $actor, StockMovement $arrival, array $plan, int $responsible): void + { + foreach ($plan as $allocation) { + $identity = SupplyRecord::query()->whereKey($allocation['lot_id'])->firstOrFail(); + $metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); + $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $arrival->warehouse_id)->first(); + $this->allocate($identity->id, $arrival->warehouse_id, $allocation['quantity'], $arrival); + $this->append($actor, 'lot_state', 'held', ['lot_id' => $identity->id, 'metadata_id' => $metadata->id, 'stock_item_id' => $arrival->stock_item_id, 'warehouse_id' => $arrival->warehouse_id], + ['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), $actor->id, $responsible])), + 'transfer' => ['movement_id' => $arrival->id, 'operation_id' => $arrival->stock_operation_id, 'quantity_milli' => $allocation['quantity'], 'previous_state_id' => $state?->id]], + 'Entrada por transferência; o lote neste local aguarda nova conferência independente.', $state); + } + $context = $this->context($arrival->stock_item_id, $arrival->warehouse_id); + $this->check($context !== null && $context['reconciled'], 'destination_warehouse_id', 'A transferência não reconciliou os lotes no destino.'); + } + + /** Freeze only the selected lots, without exposing unrelated quality history. + * @param list $plan + * @return list> + */ + public function partitionSnapshot(array $plan, int $warehouse): array + { + $rows = []; + foreach ($plan as $entry) { + $identity = SupplyRecord::query()->whereKey($entry['lot_id'])->firstOrFail(); + $metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); + $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $warehouse)->first(); + $rows[] = [...$entry, 'metadata_id' => $metadata->id, 'source_quality_id' => $state?->id, + 'code' => $metadata->data['code'], 'expires_on' => $metadata->data['expires_on'], 'no_expiry' => $metadata->data['no_expiry']]; + } + + return $rows; + } + + /** @param array $row */ + public function found(User $actor, StockOperation $count, array $row): SupplyRecord + { + return $this->append($actor, 'lot', 'registered', ['stock_item_id' => $count->stock_item_id, 'warehouse_id' => $count->warehouse_id, + 'code' => $row['code'], 'expires_on' => $row['expires_on'], 'no_expiry' => $row['no_expiry'], 'quantity_milli' => $row['counted_milli']], + ['contributors' => array_values(array_unique([$count->created_by, $count->responsible_id])), 'origin' => ['count_id' => $count->id, 'found_key' => $row['new_key']]], + 'Lote encontrado na contagem; origem física preservada, sem inventar recepção de fornecedor.'); + } + + /** @param list> $rows */ + public function adjusted(User $actor, StockOperation $count, StockMovement $movement, array $rows, int $responsible): void + { + foreach ($rows as $row) { + if ($row['delta_milli'] === 0 || ($row['delta_milli'] > 0) !== ($movement->quantity_milli > 0)) { + continue; + } + $this->allocate($row['lot_id'], $movement->warehouse_id, $row['delta_milli'], $movement); + if ($row['delta_milli'] > 0) { + $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $row['lot_id'])->where('warehouse_id', $movement->warehouse_id)->first(); + $this->append($actor, 'lot_state', 'held', ['lot_id' => $row['lot_id'], 'metadata_id' => $row['metadata_id'], 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], + ['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), $actor->id, $responsible, $count->created_by, $count->responsible_id])), + 'adjustment' => ['movement_id' => $movement->id, 'count_id' => $count->id, 'quantity_milli' => $row['delta_milli']]], + 'Quantidade encontrada em contagem; o lote neste local aguarda conferência independente de qualidade.', $state); + } + } + } + + /** @param list> $parts + * @return list + */ + public function reverseMovement(User $actor, SupplyRecord $reversal, StockMovement $movement, array $parts, int $sourceMovement): array + { + $links = []; + $contributors = array_values(array_unique([...$reversal->snapshot['contributors'], $reversal->snapshot['reviewed_by'], $actor->id])); + foreach ($parts as $part) { + if ($part['lot_id'] === null) { + $identity = $this->append($actor, 'lot', 'registered', ['stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id, + 'code' => null, 'expires_on' => null, 'no_expiry' => false, 'quantity_milli' => $part['quantity']], + ['contributors' => $contributors, 'origin' => ['reversal_key' => $reversal->record_key, 'source_movement_id' => $sourceMovement]], + 'Reposição de uma saída anterior ao controlo por lotes; identidade desconhecida, sem recepção de fornecedor presumida.'); + } else { + $identity = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->whereKey($part['lot_id'])->firstOrFail(); + } + $this->allocate($identity->id, $movement->warehouse_id, $part['quantity'], $movement); + if ($part['quantity'] > 0) { + $metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); + $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $movement->warehouse_id)->first(); + $this->append($actor, 'lot_state', 'held', ['lot_id' => $identity->id, 'metadata_id' => $metadata->id, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], + ['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), ...$contributors])), 'reversal' => ['record_key' => $reversal->record_key, 'movement_id' => $movement->id, 'source_movement_id' => $sourceMovement]], + 'Quantidade reposta por estorno; o lote no local exige nova conferência independente de qualidade.', $state); + } + $links[] = ['movement_id' => $movement->id, 'lot_id' => $identity->id, 'quantity' => $part['quantity']]; + } + + return $links; + } + + /** @param array $data + * @param array $snapshot + */ + private function append(User $actor, string $kind, string $state, array $data, array $snapshot, string $reason, ?SupplyRecord $old = null, ?string $operation = null, ?string $hash = null): SupplyRecord + { + $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old->record_key ?? (string) Str::uuid(), + 'version' => ($old->version ?? 0) + 1, 'state' => $state, 'stock_item_id' => $data['stock_item_id'], 'warehouse_id' => $data['warehouse_id'], + 'data' => $data, 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $reason, 'created_by' => $actor->id, + 'operation_key' => $operation ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)), 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'inventory.'.$kind.'.'.$state, null, $old?->toArray(), $record->toArray()); + app(AutomaticPurchases::class)->signal($actor,$data['stock_item_id'],$data['warehouse_id']); + + return $record; + } + + private function check(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/InventoryReversals.php b/app/Services/InventoryReversals.php new file mode 100644 index 0000000..eb1e426 --- /dev/null +++ b/app/Services/InventoryReversals.php @@ -0,0 +1,237 @@ +maintenanceOnly($actor) && $actor->hasFunctions(match ($action) { + 'read' => ['inventory_reversal_read', 'admin', PurchasingRole::Warehouse], 'execute' => [PurchasingRole::Warehouse], default => ['inventory_reversal_'.$action], + }); + } + + /** @return array */ + public function corrections(string $sourceKind): array + { + if (! Schema::hasTable('supply_records')) { + return []; + } + $key = $sourceKind === 'operation' ? 'source_operation_id' : 'receipt_item_id'; + $result = []; + foreach (app(SupplyPlanning::class)->latest('stock_reversal')->where('state', 'executed')->whereJsonContains('data->source_kind', $sourceKind)->get() as $record) { + $result[$record->data[$key]] = $record; + } + + return $result; + } + + public function operationReversed(int $id): bool + { + return isset($this->corrections('operation')[$id]); + } + + public function receiptReversed(int $id): bool + { + return isset($this->corrections('receipt')[$id]); + } + + /** @return list> */ + public function financialNotices(): array + { + if (! Schema::hasTable('supply_records')) { + return []; + } + + return array_values(app(SupplyPlanning::class)->latest('stock_reversal')->where('state', 'executed')->latest('id')->limit(100)->get() + ->filter(fn ($r) => $r->data['source_kind'] === 'receipt' || in_array($r->snapshot['source']['document']['operation_kind'] ?? '', ['supplier_return', 'write_off', 'adjustment'], true)) + ->map(fn ($r) => ['reference' => $r->data['reference'], 'source_reference' => $r->snapshot['source']['document']['reference'], 'item_name' => $r->snapshot['source']['item']['name'], 'event_on' => $r->data['event']['event_on']])->all()); + } + + public function voidedReceiptQuantity(int $line, string $column): int + { + if (! in_array($column, ['received_milli', 'accepted_milli'], true)) { + throw new \InvalidArgumentException('Quantidade de recepção desconhecida.'); + } + $sum = 0; + foreach ($this->corrections('receipt') as $record) { + $source = $record->snapshot['source']['receipt']; + if ($source['purchase_order_item_id'] === $line) { + $sum += $source[$column]; + } + } + + return $sum; + } + + /** @param array $data + * @return array{source:array,observed:list>} + */ + public function basis(array $data): array + { + $receiptSource = null; + if ($data['source_kind'] === 'operation') { + $operation = StockOperation::query()->whereKey($data['source_operation_id'])->firstOrFail(); + $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(); + $itemId = $operation->stock_item_id; + $sourceDocument = ['kind' => 'operation', 'id' => $operation->id, 'reference' => $operation->reference, 'operation_kind' => $operation->kind]; + } else { + $line = GoodsReceiptItem::query()->whereKey($data['receipt_item_id'])->firstOrFail(); + $receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail(); + $this->check($line->accepted_milli > 0 && $line->stock_item_id !== null, 'data.receipt_item_id', 'Este percurso estorna uma linha física aceite com movimento de stock.'); + $this->check(! $this->receiptReversed($line->id), 'action', 'Esta linha de recepção já foi integralmente estornada.'); + $this->check(app(InventoryDispositions::class)->returnedForReceipt($line->id) === 0, 'action', 'Existem devoluções efectivas desta origem. Reconcilie-as antes de estornar a recepção inteira.'); + $movements = StockMovement::query()->where('goods_receipt_item_id', $line->id)->orderBy('id')->get(); + $itemId = $line->stock_item_id; + $sourceDocument = ['kind' => 'receipt', 'id' => $line->id, 'reference' => $receipt->number]; + $receiptSource = ['item_id' => $line->id, 'receipt_id' => $receipt->id, 'purchase_order_item_id' => $line->purchase_order_item_id, + 'purchase_order_id' => $receipt->purchase_order_id, 'received_milli' => $line->received_milli, 'accepted_milli' => $line->accepted_milli]; + } + $this->check($movements->isNotEmpty(), 'action', 'A origem não tem movimentos físicos comprovados.'); + $item = StockItem::query()->whereKey($itemId)->firstOrFail(); + $rows = []; + $observed = []; + foreach ($movements as $movement) { + $originalUnit = $movement->snapshot['stock_item']['unit'] ?? null; + $this->check($movement->stock_item_id === $item->id && is_string($originalUnit) && Str::lower(trim($originalUnit)) === Str::lower(trim($item->unit)), 'action', 'A unidade ou artigo mudou ou falta na origem. Reconcilie sem conversões presumidas.'); + $inverse = BigDecimal::of($movement->quantity_milli)->negated()->toInt(); + $this->check($inverse !== 0, 'action', 'A origem contém um movimento físico nulo; confira a integridade do registo.'); + $warehouse = Warehouse::query()->whereKey($movement->warehouse_id)->firstOrFail(); + $balance = app(StockWorkflow::class)->balance($item->id, $warehouse->id); + $this->check($inverse > 0 || $balance['quantity'] >= -$inverse, 'action', 'A quantidade a retirar no estorno já não existe neste local. Não há saldo negativo nem compensação com outro local.'); + $setup = app(InventoryLots::class)->setup($item->id, $warehouse->id); + $parts = []; + $allocations = Schema::hasTable('inventory_lot_allocations') ? InventoryLotAllocation::query()->where('stock_movement_id', $movement->id)->orderBy('lot_id')->get() : collect(); + $this->check($setup !== null || $allocations->isEmpty(), 'action', 'Preserve o controlo activo dos lotes da origem.'); + if ($setup !== null) { + $context = app(InventoryLots::class)->context($item->id, $warehouse->id); + $this->check($context !== null && $context['reconciled'], 'action', 'Confira a repartição por lotes antes do estorno.'); + if ($allocations->isEmpty()) { + $this->check($inverse > 0, 'action', 'A entrada original não tinha repartição por lote. Não presuma que o saldo inicial corresponde a essa recepção; reconcilie a origem.'); + $parts[] = ['lot_id' => null, 'metadata_id' => null, 'quantity' => $inverse, 'code' => null]; + } else { + $total = 0; + foreach ($allocations as $allocation) { + $quantity = BigDecimal::of($allocation->quantity_milli)->negated()->toInt(); + $lot = collect($context['lots'])->firstWhere('id', $allocation->lot_id); + $this->check($lot !== null && ($quantity > 0 || $lot['quantity_milli'] >= -$quantity), 'action', 'A quantidade do lote original já não existe neste local. Não substitua por outro lote.'); + $parts[] = ['lot_id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'quantity' => $quantity, 'code' => $lot['data']['code']]; + $total += $quantity; + } + $this->check($total === $inverse, 'action', 'A repartição original não corresponde ao movimento integral.'); + } + } + $rows[] = ['source_movement_id' => $movement->id, 'warehouse_id' => $warehouse->id, 'warehouse_name' => $warehouse->name, + 'equipment_id' => $movement->equipment_id, 'spare_part_id' => $movement->spare_part_id, + 'equipment' => $movement->snapshot['equipment'] ?? ($movement->equipment_id === null ? null : Equipment::query()->whereKey($movement->equipment_id)->firstOrFail()->only(['id', 'code', 'description'])), + 'original_quantity_milli' => -$inverse, 'quantity_milli' => $inverse, 'original_type' => $movement->movement_type, 'setup_id' => $setup?->id, 'lots' => $parts]; + $observed[] = ['warehouse_id' => $warehouse->id, 'balance' => $balance]; + } + usort($rows, fn ($a, $b) => [$a['quantity_milli'] > 0, $a['source_movement_id']] <=> [$b['quantity_milli'] > 0, $b['source_movement_id']]); + + return ['source' => ['document' => $sourceDocument, 'item' => $item->only(['id', 'code', 'name', 'unit']), 'rows' => $rows, 'receipt' => $receiptSource], 'observed' => $observed]; + } + + /** @param array $input */ + public function save(User $actor, array $input): SupplyRecord + { + return DB::transaction(function () use ($actor, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:save,approve,return,execute', + 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + abort_unless($this->allows($actor, match ($v['action']) { + 'save' => 'prepare', 'execute' => 'execute', default => 'review' + }), 403); + $hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR)); + $retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first(); + if ($retry) { + $this->check($retry->input_hash === $hash, 'operation_key', 'Chave já usada com outros dados.'); + + return $retry; + } + $old = empty($v['record_key']) ? null : app(SupplyPlanning::class)->latest('stock_reversal')->where('record_key', $v['record_key'])->firstOrFail(); + $this->check((int) $v['version'] === ($old->version ?? 0), 'version', 'O processo mudou. Actualize a página.'); + if ($v['action'] === 'save') { + $this->check($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Devolva para actualização antes de alterar a decisão.'); + $data = Validator::make($v['data'], ['source_kind' => 'required|in:operation,receipt', 'source_operation_id' => 'required_if:source_kind,operation|nullable|integer|min:1', + 'receipt_item_id' => 'required_if:source_kind,receipt|nullable|integer|min:1', 'reference' => 'required|string|max:100'])->validate(); + $data['source_operation_id'] = $data['source_kind'] === 'operation' ? (int) $data['source_operation_id'] : null; + $data['receipt_item_id'] = $data['source_kind'] === 'receipt' ? (int) $data['receipt_item_id'] : null; + $data['reference'] = Str::upper(Str::squish($data['reference'])); + $this->check($data['reference'] !== '' && ! StockOperation::query()->where('reference', $data['reference'])->exists(), 'data.reference', 'Indique referência única ainda não usada no stock.'); + foreach (['stock_event', 'stock_reversal'] as $kind) { + $clash = app(SupplyPlanning::class)->latest($kind)->whereJsonContains('data->reference', $data['reference']); + if ($old !== null) { + $clash->where('record_key', '!=', $old->record_key); + } + $this->check(! $clash->exists(), 'data.reference', 'Referência já usada noutro processo de stock.'); + } + $snapshot = $this->basis($data); + $snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])); + $state = 'draft'; + } else { + $this->check($old !== null, 'action', 'Prepare primeiro o estorno.'); + $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.'); + $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); + $this->check($basis['source'] === $snapshot['source'], 'action', 'A origem, identificação ou configuração mudou. Devolva para nova preparação.'); + $result = app(StockWorkflow::class)->reverse($actor, $old); + $data['stock_operation_id'] = $result['operation']->id; + $data['event'] = $event; + $snapshot['execution_lots'] = $result['lots']; + $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($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') { + $basis = $this->basis($data); + $this->check($basis['source'] === $snapshot['source'], 'action', 'A origem mudou; actualize a preparação.'); + $snapshot['reviewed_by'] = $actor->id; + $snapshot['review_observed'] = $basis['observed']; + } + } + } + $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'stock_reversal', 'record_key' => $old->record_key ?? (string) Str::uuid(), 'version' => ($old->version ?? 0) + 1, + 'state' => $state, 'stock_item_id' => $snapshot['source']['item']['id'], 'warehouse_id' => $snapshot['source']['rows'][0]['warehouse_id'], 'created_by' => $actor->id, + 'data' => $data, 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $v['reason'], 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'inventory.reversal.'.$v['action'], null, $old?->toArray(), $record->toArray()); + + return $record; + }); + } + + private function check(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/InvoiceWorkflow.php b/app/Services/InvoiceWorkflow.php index a655eee..0718615 100644 --- a/app/Services/InvoiceWorkflow.php +++ b/app/Services/InvoiceWorkflow.php @@ -44,6 +44,8 @@ class InvoiceWorkflow $quantity = ReceiptWorkflow::milli((string) $input['quantity']); $previous = InvoiceItem::query()->where('purchase_order_item_id', $source->id)->whereIn('invoice_id', Invoice::query()->where('status', 'validated')->select('id'))->sum('quantity_milli'); $accepted = GoodsReceiptItem::query()->where('purchase_order_item_id', $source->id)->sum('accepted_milli'); + $accepted = (int) $accepted - app(InventoryDispositions::class)->returnedForOrderLine($source->id); + $accepted -= app(InventoryReversals::class)->voidedReceiptQuantity($source->id, 'accepted_milli'); if ($quantity <= 0 || (int) $previous + $quantity > (int) $accepted || (int) $previous + $quantity > ReceiptWorkflow::milli($source->quantity)) { $this->invalid('Quantidade superior à recepção aceite ainda não facturada. Serviços também exigem aceitação.'); } diff --git a/app/Services/OrganizationAccess.php b/app/Services/OrganizationAccess.php index edb92a9..b42ae3e 100644 --- a/app/Services/OrganizationAccess.php +++ b/app/Services/OrganizationAccess.php @@ -10,6 +10,38 @@ use Illuminate\Support\Facades\Schema; class OrganizationAccess { public const PERMISSIONS = [ + 'warehouse_request_review' => 'Autorizar ou rejeitar requisições internas ao armazém do departamento, de forma independente', + 'warehouse_request_deliver' => 'Entregar material de requisições internas autorizadas (exige função de armazém)', + 'warehouse_request_prepare' => 'Preparar requisições próprias ao armazém com destino de utilização', + 'warehouse_request_read' => 'Consultar requisições ao armazém da empresa', + 'warehouse_availability_read' => 'Consultar disponibilidade e necessidades planeadas para requisições ao armazém', + 'inventory_lot_read' => 'Consultar lotes, validade e quarentena', + 'inventory_lot_prepare' => 'Preparar controlo por lotes e correcções de identificação', + 'inventory_lot_review' => 'Conferir activação e parâmetros do controlo por lotes', + 'inventory_lot_hold' => 'Colocar lotes em quarentena', + 'inventory_lot_release' => 'Conferir identificação e libertar lotes de forma independente', + 'inventory_adjust_review' => 'Conferir e aplicar ajustes físicos por lote de forma independente', + 'inventory_disposition_read' => 'Consultar devoluções a fornecedores e abates físicos', + 'inventory_disposition_prepare' => 'Preparar devoluções a fornecedores e abates físicos', + 'inventory_disposition_review' => 'Conferir devoluções e abates de forma independente', + 'inventory_reversal_read' => 'Consultar estornos integrais de stock e respectivas origens', + 'inventory_reversal_prepare' => 'Preparar estornos de stock ligados ao original', + 'inventory_reversal_review' => 'Conferir estornos de stock de forma independente', + 'supply_read' => 'Consultar fornecimentos e calendário de reposição', + 'supply_prepare' => 'Preparar acordos habituais e políticas de reposição', + 'supply_review' => 'Conferir e aprovar acordos e políticas de reposição', + 'supply_suggest_prepare' => 'Preparar e actualizar sugestões de reposição', + 'supply_suggest_review' => 'Conferir e decidir sugestões de reposição', + 'supply_suggest_convert' => 'Converter sugestões aprovadas em requisições próprias', + 'supply_history_prepare' => 'Preparar análise e cobertura do histórico de consumo', + 'supply_history_review' => 'Conferir o histórico de consumo de forma independente', + 'supply_reserve' => 'Reservar necessidades e conferir satisfação por saídas', + 'supply_delivery' => 'Registar confirmações e replaneamento de entregas', + 'supply_technical_prepare' => 'Preparar equivalências técnicas de artigos', + 'supply_technical_review' => 'Validar equivalências técnicas de forma independente', + 'import_read' => 'Consultar processos de importação', + 'import_prepare' => 'Preparar e acompanhar logística de importações', + 'import_customs' => 'Conferir documentação e libertação aduaneira', 'requester' => 'Solicitar e acompanhar compras próprias', 'department_head' => 'Supervisão e aprovação do departamento', 'procurement' => 'Preparação e gestão operacional de compras', @@ -30,7 +62,7 @@ class OrganizationAccess } /** @return list */ - public function permissions(User $user): array + public function permissions(User $user, ?string $context = null): array { if ($user->tenant_id === null) { return []; @@ -38,11 +70,25 @@ class OrganizationAccess if (! $this->active($user->tenant_id)) { return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value]; } + $contextKey = $context; + if ($contextKey === null && app()->bound('request') && request()->hasSession() && WorkContext::area(request()->path()) !== null && app(WorkContext::class)->enforced($user)) { + $contextKey = app(WorkContext::class)->active(request()); + if ($contextKey === null) { + return []; + } + } + $columns = ['p.permissions']; + if (Schema::hasColumn('organization_profiles', 'work_contexts')) { + $columns[] = 'p.work_contexts'; + } $rows = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id') ->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id) - ->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.permissions']); + ->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get($columns); $permissions = []; foreach ($rows as $row) { + if ($contextKey !== null && ! in_array($contextKey, json_decode($row->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR), true)) { + continue; + } $permissions = [...$permissions, ...json_decode($row->permissions, true, 32, JSON_THROW_ON_ERROR)]; } diff --git a/app/Services/OrganizationConfiguration.php b/app/Services/OrganizationConfiguration.php index 88b6673..b335a54 100644 --- a/app/Services/OrganizationConfiguration.php +++ b/app/Services/OrganizationConfiguration.php @@ -56,13 +56,27 @@ class OrganizationConfiguration } $before = $profile ? (array) $profile : null; $values = ['name' => $data['name'], 'permissions' => json_encode(array_values(array_unique($data['permissions'])), JSON_THROW_ON_ERROR), 'updated_at' => now()]; + if (array_key_exists('work_contexts', $data)) { + abort_unless(Schema::hasColumn('organization_profiles', 'work_contexts'), 503, 'Instale a migração de contexto antes de associar perfis.'); + $values['work_contexts'] = json_encode($data['work_contexts'], JSON_THROW_ON_ERROR); + } if ($profile) { DB::table('organization_profiles')->where('id', $profile->id)->update([...$values, 'version' => $profile->version + 1]); $id = $profile->id; } else { $id = DB::table('organization_profiles')->insertGetId([...$values, 'tenant_id' => $actor->tenant_id, 'created_at' => now()]); } - $after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions']]; + $after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions'], 'work_contexts' => $data['work_contexts'] ?? null]; + } elseif ($command === 'work_contexts') { + abort_unless(Schema::hasColumn('tenants', 'work_context_policy'), 503, 'Instalação do contexto de trabalho pendente.'); + $company = Tenant::query()->findOrFail($actor->tenant_id); + $before = $company->only(['work_context_policy']); + if (array_diff($data['enabled'], app(WorkContext::class)->purchased($actor)) !== []) { + throw ValidationException::withMessages(['enabled' => 'Escolha apenas opções contratadas para esta empresa.']); + } + $policy = ['revision' => ($company->work_context_policy['revision'] ?? 0) + 1, 'enabled' => $data['enabled']]; + $company->update(['work_context_policy' => $policy]); + $after = ['work_context_policy' => $policy, 'reason' => $data['reason'], 'effect' => 'access_immediate_existing_processes_unchanged']; } elseif ($command === 'user') { $department = DB::table('departments')->where('tenant_id', $actor->tenant_id)->where('id', $data['department_id'])->exists(); abort_unless($department, 422, 'Departamento não autorizado.'); @@ -161,6 +175,7 @@ class OrganizationConfiguration public function initialise(User $first, User $second): void { + Tenant::query()->whereKey($first->tenant_id)->update(['work_context_policy' => json_encode(['revision' => 1, 'enabled' => []], JSON_THROW_ON_ERROR)]); $profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $first->tenant_id, 'name' => 'Administração da empresa', 'permissions' => json_encode(['admin'], JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]); foreach ([$first, $second] as $user) { $this->assign($first, $user->id, [$profile], false, null, 'Configuração inicial e entrega do produto.'); diff --git a/app/Services/PeopleApplicationReport.php b/app/Services/PeopleApplicationReport.php new file mode 100644 index 0000000..0eeb8f5 --- /dev/null +++ b/app/Services/PeopleApplicationReport.php @@ -0,0 +1,31 @@ + */ + public function counts(PeopleProcess $process, User $actor): array + { + $frozen = in_array($process->status, ['selected', 'contract_draft'], true); + $rows = $frozen ? ($process->selection['rows'] ?? []) : app(PeopleHiring::class)->matrix($process); + $complete = count(array_filter($rows, fn ($row) => $row['score_minor'] !== null)); + $started = count(array_filter($rows, fn ($row) => $row['evaluation_count'] > 0)); + $triage = app(CandidateTriage::class); + $portal = $triage->canRead($actor, $process) && Schema::hasTable('candidate_triage'); + $received = $portal ? DB::table('candidate_submissions')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->count() : null; + $decisions = $portal ? DB::table('candidate_triage')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->get(['outcome']) : collect(); + + return ['registered' => count($rows), 'evaluation_complete' => $complete, 'evaluation_pending' => count($rows) - $complete, + 'evaluation_unstarted' => count($rows) - $started, 'evaluation_in_progress' => $started - $complete, + 'basis' => $frozen ? 'selection_snapshot' : 'current_eligibility', + 'portal_received' => $received, 'portal_accepted' => $portal ? $decisions->where('outcome', 'accepted_for_analysis')->count() : null, + 'portal_rejected' => $portal ? $decisions->where('outcome', 'not_accepted')->count() : null, + 'portal_pending' => $portal ? $triage->pending($process) : null]; + } +} diff --git a/app/Services/PeopleContractHandoff.php b/app/Services/PeopleContractHandoff.php new file mode 100644 index 0000000..05b6637 --- /dev/null +++ b/app/Services/PeopleContractHandoff.php @@ -0,0 +1,29 @@ + */ + public function envelope(PeopleProcess $process): array + { + abort_if($process->isDirty(), 409, 'A ligação a RH exige uma versão persistida do contrato formalizado.'); + // Selection approval and today's contract draft must never cross this boundary. + abort_unless($process->exists && $process->getAttribute('work_context_key') === 'people.internal' + && $process->status === 'contract_formalized' + && ($process->contract['status'] ?? null) === 'formalized' + && ! empty($process->contract['formalized_at']) + && ! empty($process->contract['reference']) + && $process->selected_application_id !== null, 409, 'A ligação a RH exige contrato interno formalizado.'); + + return ['schema' => 'sgc.people.contract_formalized.v1', + 'idempotency_key' => 'sgc:'.$process->tenant_id.':people:'.$process->id.':contract:'.$process->version, + 'tenant_id' => $process->tenant_id, 'process_id' => $process->id, 'process_version' => $process->version, + 'application_id' => $process->selected_application_id, 'kind' => $process->kind, + 'work_context_key' => $process->getAttribute('work_context_key'), + 'contract' => $process->contract]; + } +} diff --git a/app/Services/PeopleDeadlineReport.php b/app/Services/PeopleDeadlineReport.php new file mode 100644 index 0000000..e699025 --- /dev/null +++ b/app/Services/PeopleDeadlineReport.php @@ -0,0 +1,46 @@ + $query + * @return array + */ + public function build(Builder $query, User $actor, string $window = 'all'): array + { + $desk = app(PeopleWorkdesk::class)->build($query, $actor); + $today = CarbonImmutable::parse($desk['updated_at'])->setTimezone(config('app.timezone'))->startOfDay(); + $items = []; + foreach (['tasks' => 'Tarefa', 'approvals' => 'Conferência/decisão', 'alerts' => 'Alerta do processo'] as $group => $label) { + foreach ($desk[$group] as $item) { + $due = $item['due_on']; + $days = $due === null ? null : max(0, (int) CarbonImmutable::parse($due, config('app.timezone'))->startOfDay()->diffInDays($today)); + $items[] = ['id' => $group.':'.$item['id'], 'process_id' => $item['process_id'], 'reference' => $item['reference'], 'title' => $item['title'], + 'category' => $label, 'label' => $item['label'], 'responsibility' => $group === 'alerts' ? 'Alerta do processo; sem atribuição pessoal' : 'Minha intervenção autorizada', + 'due_on' => $due, 'days_overdue' => $days, 'href' => $item['href']]; + } + } + $items = array_values(array_filter($items, fn ($item) => match ($window) { + 'overdue' => ($item['days_overdue'] ?? 0) > 0, + 'today' => $item['due_on'] === $today->toDateString(), + 'future' => $item['due_on'] !== null && $item['due_on'] > $today->toDateString(), + 'undated' => $item['due_on'] === null, + default => true, + })); + usort($items, fn ($a, $b) => (($b['days_overdue'] ?? -1) <=> ($a['days_overdue'] ?? -1)) ?: strcmp($a['due_on'] ?? '9999', $b['due_on'] ?? '9999') ?: strcmp($a['id'], $b['id'])); + + return ['as_of' => $today->toDateString(), 'window' => $window, 'processes_considered' => $desk['counts']['processes'], + 'matching_processes' => count(array_unique(array_column($items, 'process_id'))), 'total_items' => count($items), + 'tasks' => count(array_filter($items, fn ($item) => $item['category'] === 'Tarefa')), + 'approvals' => count(array_filter($items, fn ($item) => $item['category'] === 'Conferência/decisão')), + 'alerts' => count(array_filter($items, fn ($item) => $item['category'] === 'Alerta do processo')), 'items' => $items, + 'without_deadline' => count(array_filter($items, fn ($item) => $item['due_on'] === null)), + 'overdue_items' => count(array_filter($items, fn ($item) => ($item['days_overdue'] ?? 0) > 0))]; + } +} diff --git a/app/Services/PeopleSelectionReport.php b/app/Services/PeopleSelectionReport.php new file mode 100644 index 0000000..c599051 --- /dev/null +++ b/app/Services/PeopleSelectionReport.php @@ -0,0 +1,37 @@ + */ + public function facts(PeopleProcess $process, User $actor): array + { + $access = app(PeopleAccess::class); + $contract = $access->can($actor, $process, 'contract') || $access->can($actor, $process, 'contract_view'); + $selection = $process->selection ?? []; + $state = match ($process->status) { + 'selection_review' => 'Proposta por conferir', + 'selected', 'contract_draft' => 'Selecção aprovada', + default => ($selection['outcome'] ?? null) === 'returned' ? 'Proposta devolvida' : 'Sem proposta de selecção', + }; + $pending = match ($process->status) { + 'draft' => 'Conferir abertura', 'open' => 'Concluir recepção e triagem de candidaturas', + 'assessment' => ($selection['outcome'] ?? null) === 'returned' ? 'Rever avaliação e proposta devolvida' : 'Concluir avaliação e preparar proposta', + 'selection_review' => 'Conferir proposta por pessoa independente', + 'selected' => 'Preparar minuta contratual', + 'contract_draft' => 'Minuta preparada; formalização não registada neste percurso', + default => 'Consultar processo', + }; + + return ['selection_state' => $state, 'proposed_at' => $selection['created_at'] ?? null, + 'decided_at' => $selection['decision_at'] ?? null, 'pending' => $pending, + 'contract_visibility' => $contract ? 'Consulta autorizada' : 'Sem acesso aos dados da minuta', + 'contract_reference' => $contract ? ($process->contract['reference'] ?? null) : 'Sem acesso', + 'contract_prepared_at' => $contract ? ($process->contract['prepared_at'] ?? null) : 'Sem acesso', + 'contract_starts_on' => $contract ? ($process->contract['starts_on'] ?? null) : 'Sem acesso']; + } +} diff --git a/app/Services/ProposalComparison.php b/app/Services/ProposalComparison.php index 4199e08..3f8fc5f 100644 --- a/app/Services/ProposalComparison.php +++ b/app/Services/ProposalComparison.php @@ -8,10 +8,10 @@ use App\Models\Quotation; use App\Models\Requisition; use App\Models\User; use Brick\Math\BigDecimal; -use Brick\Math\BigRational; use Brick\Math\Exception\MathException; use Brick\Math\RoundingMode; use Illuminate\Validation\ValidationException; +use Tempo\Purchasing\Economic\ProposalScoring; class ProposalComparison { @@ -54,35 +54,13 @@ class ProposalComparison ]; } $eligible = array_values(array_filter($rows, fn ($row) => $row['eligible'])); - $minimum = null; - $days = null; - foreach ($eligible as $row) { - $minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']); - $days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']); - } - $best = null; - $topIds = []; + $ranking = (new ProposalScoring)->rank($rows, $policy['minimum_evaluators']); + $topIds = $ranking['top_ids']; foreach ($rows as &$row) { - if ($row['eligible'] && $row['evaluation_count'] >= $policy['minimum_evaluators']) { - $weights = $row['weights']; - $score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price']) - ->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery'])); - foreach (['quality', 'history', 'payment'] as $criterion) { - $sum = BigRational::zero(); - foreach ($row['qualified_evaluations'] as $evaluation) { - $sum = $sum->plus($evaluation[$criterion]); - } - $score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion])); - } - $minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt(); + if (isset($ranking['scores'][$row['id']])) { + $minor = $ranking['scores'][$row['id']]; $row['score_minor'] = $minor; $row['score'] = $minor / 100; - if ($best === null || $minor > $best) { - $best = $minor; - $topIds = [$row['id']]; - } elseif ($minor === $best) { - $topIds[] = $row['id']; - } } } unset($row); diff --git a/app/Services/QuotationPricing.php b/app/Services/QuotationPricing.php index 0365cbe..6fac92a 100644 --- a/app/Services/QuotationPricing.php +++ b/app/Services/QuotationPricing.php @@ -2,9 +2,8 @@ namespace App\Services; -use Brick\Math\BigDecimal; -use Brick\Math\RoundingMode; use Illuminate\Validation\ValidationException; +use InvalidArgumentException; class QuotationPricing { @@ -13,22 +12,10 @@ class QuotationPricing */ public function unit(string $price, string $type = 'none', string $value = '0'): array { - $gross = BigDecimal::of($price)->multipliedBy(100)->toInt(); - $discount = BigDecimal::of($value); - if ($gross < 1 || $discount->isNegative() || ! in_array($type, ['none', 'percent', 'amount'], true) || - ($type === 'none' && ! $discount->isZero()) || ($type === 'percent' && $discount->isGreaterThan(100))) { - throw ValidationException::withMessages(['items' => 'Indique um desconto unitário válido: percentagem ou valor em MZN, antes do IVA.']); + try { + return (new \Tempo\Purchasing\Economic\QuotationPricing)->unit($price, $type, $value); + } catch (InvalidArgumentException $error) { + throw ValidationException::withMessages(['items' => $error->getMessage()]); } - $reduction = match ($type) { - 'percent' => BigDecimal::of($gross)->multipliedBy($discount)->dividedBy(100, 4), - 'amount' => $discount->multipliedBy(100), - default => BigDecimal::zero(), - }; - $net = BigDecimal::of($gross)->minus($reduction)->toScale(0, RoundingMode::HalfUp)->toInt(); - if ($net < 1) { - throw ValidationException::withMessages(['items' => 'O preço unitário após desconto deve ser pelo menos 0,01 MZN.']); - } - - return ['gross_unit_minor' => $gross, 'discount_type' => $type, 'discount_value' => (string) $discount->toScale(2), 'net_unit_minor' => $net]; } } diff --git a/app/Services/ReceiptWorkflow.php b/app/Services/ReceiptWorkflow.php index f018bd6..536b847 100644 --- a/app/Services/ReceiptWorkflow.php +++ b/app/Services/ReceiptWorkflow.php @@ -14,6 +14,7 @@ use App\Models\Warehouse; use Brick\Math\BigDecimal; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -26,14 +27,17 @@ class ReceiptWorkflow return BigDecimal::of($quantity)->multipliedBy(1000)->toScale(0)->toInt(); } - private function recordedQuantity(int $lineId, string $column): int + public function recordedQuantity(int $lineId, string $column): int { + if (! in_array($column, ['received_milli', 'accepted_milli'], true)) { + throw new \InvalidArgumentException('Quantidade de recepção desconhecida.'); + } $sum = GoodsReceiptItem::query()->where('purchase_order_item_id', $lineId)->sum($column); if (! is_int($sum) && ! is_string($sum)) { throw new \LogicException('A soma das quantidades deve ser um inteiro exacto.'); } - return BigDecimal::of($sum)->toInt(); + return BigDecimal::of($sum)->minus(app(InventoryReversals::class)->voidedReceiptQuantity($lineId, $column))->toInt(); } /** @return array{lines: list>, state: string, has_divergences: bool} */ @@ -48,13 +52,16 @@ class ReceiptWorkflow $ordered = self::milli($item->quantity); $received = $this->recordedQuantity($item->id, 'received_milli'); $accepted = $this->recordedQuantity($item->id, 'accepted_milli'); + $returned = app(InventoryDispositions::class)->returnedForOrderLine($item->id); + $effective = $accepted - $returned; $lines[] = [ ...$item->only(['id', 'description', 'unit', 'purchase_type']), ...(isset($order->snapshot['catalogue_sources'][$item->requisition_item_id]['stock_item_id']) ? ['stock_item_id' => $order->snapshot['catalogue_sources'][$item->requisition_item_id]['stock_item_id']] : []), 'ordered_milli' => $ordered, 'received_milli' => $received, 'accepted_milli' => $accepted, - 'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $accepted, + 'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $effective, + ...($returned > 0 ? ['supplier_returned_milli' => $returned, 'effective_accepted_milli' => $effective] : []), ]; - $complete = $complete && $accepted === $ordered; + $complete = $complete && $effective === $ordered; $delivered = $delivered && $received === $ordered; $started = $started || $received > 0; $divergences = $divergences || GoodsReceiptItem::query()->where('purchase_order_item_id', $item->id)->where('quality_status', '!=', 'compliant')->exists(); @@ -76,9 +83,18 @@ class ReceiptWorkflow throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']); } $inputs = []; - /** @var list $requested */ + /** @var list>}> $requested */ $requested = $data['items']; + $supplyMappings = Schema::hasTable('supply_records') + ? app(SupplyPlanning::class)->latest('delivery')->where('purchase_order_id', $order->id)->get() : collect(); foreach ($requested as $line) { + $mapping = $supplyMappings->first(fn ($record) => $record->data['purchase_order_item_id'] == $line['purchase_order_item_id']); + if ($mapping !== null) { + if ((! empty($line['stock_item_id']) && (int) $line['stock_item_id'] !== $mapping->stock_item_id) || (int) ($data['warehouse_id'] ?? 0) !== $mapping->warehouse_id) { + throw ValidationException::withMessages(['items' => 'Reutilize o artigo e armazém já associados à confirmação desta linha.']); + } + $line['stock_item_id'] = $mapping->stock_item_id; + } if (! empty($order->snapshot['catalogue_sources']) && empty($line['stock_item_id'])) { $orderLine = $order->items()->whereKey($line['purchase_order_item_id'])->first(); if ($orderLine !== null && isset($order->snapshot['catalogue_sources'][$orderLine->requisition_item_id]['stock_item_id'])) { @@ -90,6 +106,7 @@ class ReceiptWorkflow } } $inputs[] = [ + ...(! empty($line['lots']) ? ['lots' => $line['lots']] : []), 'purchase_order_item_id' => $line['purchase_order_item_id'], 'received_milli' => self::milli($line['received_quantity']), 'accepted_milli' => self::milli($line['accepted_quantity']), 'quality_status' => $line['quality_status'], 'stock_item_id' => $line['stock_item_id'] ?? null, @@ -117,6 +134,12 @@ class ReceiptWorkflow if (GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('delivery_note', $deliveryNote)->exists()) { throw ValidationException::withMessages(['delivery_note' => 'Esta guia já foi confirmada nesta ordem. Consulte o histórico em vez de repetir a recepção.']); } + if (Schema::hasTable('supply_records')) { + $import = app(SupplyPlanning::class)->latest('import')->where('purchase_order_id', $order->id)->first(); + if ($import !== null && $import->state !== 'released') { + throw ValidationException::withMessages(['items' => 'A importação ainda não tem libertação aduaneira comprovada. A chegada ou o pagamento não autorizam entrada utilizável.']); + } + } if ($order->status !== 'issued' || $data['received_on'] < substr((string) $order->getRawOriginal('issued_at'), 0, 10)) { throw ValidationException::withMessages(['received_on' => 'A recepção deve referir uma ordem emitida e não pode anteceder a sua emissão.']); } @@ -193,12 +216,15 @@ class ReceiptWorkflow foreach ($lines as $line) { $item = $receipt->items()->create($line); if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) { - StockMovement::query()->create([ + $movement = StockMovement::query()->create([ 'tenant_id' => $user->tenant_id, 'goods_receipt_item_id' => $item->id, 'stock_item_id' => $line['stock_item_id'], 'warehouse_id' => $warehouse?->id, 'moved_by' => $user->id, 'quantity_milli' => $line['accepted_milli'], 'moved_at' => now(), 'equipment_id' => $line['snapshot']['equipment_id'], 'spare_part_id' => $line['snapshot']['spare_part_id'], 'snapshot' => [...$line['snapshot'], 'receipt_id' => $receipt->id, 'order_number' => $order->number, 'delivery_note' => $deliveryNote], ]); + $sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']); + app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']); + app(AutomaticPurchases::class)->signal($user,$line['stock_item_id'],$warehouse->id); } } $progress = $this->progress($order); diff --git a/app/Services/ReplenishmentProjection.php b/app/Services/ReplenishmentProjection.php new file mode 100644 index 0000000..cee7ebc --- /dev/null +++ b/app/Services/ReplenishmentProjection.php @@ -0,0 +1,19 @@ + $policy + * @param list $reservations + * @param list $receipts + * @param list $fulfilledNeeds + * @param list|null $lotBuckets + * @return array + */ + public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array + { + return (new \Tempo\Purchasing\Supply\ReplenishmentProjection)->calculate($policy, $physical, $start, $reservations, $receipts, $consumedToday, $fulfilledNeeds, $lotBuckets); + } +} diff --git a/app/Services/RequisitionWorkflow.php b/app/Services/RequisitionWorkflow.php index 899dca1..0da6869 100644 --- a/app/Services/RequisitionWorkflow.php +++ b/app/Services/RequisitionWorkflow.php @@ -5,22 +5,60 @@ namespace App\Services; use App\Models\AuditLog; use App\Models\CostCenter; use App\Models\Requisition; +use App\Models\SupplyRecord; use App\Models\Tenant; use App\Models\User; -use Brick\Math\BigDecimal; -use Brick\Math\RoundingMode; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Validation\ValidationException; +use Tempo\Purchasing\Internal\InternalRequisitions; +use Tempo\Purchasing\Internal\RequisitionFailure; class RequisitionWorkflow { - /** @param array $data */ + private function process(User $user): InternalRequisitions + { + return new InternalRequisitions(new InternalRequisitionPort($user, fn (array $data, ?Requisition $existing) => $this->persistDraft($user, $data, $existing))); + } + + /** @param array $data */ public function save(User $user, array $data, ?Requisition $existing = null): Requisition { - return DB::transaction(function () use ($user, $data, $existing) { + $key = $data['operation_key'] ?? null; + $version = isset($data['process_version']) ? (int) $data['process_version'] : null; + unset($data['operation_key'], $data['process_version']); + $result = $this->execute($user, 'save', $existing?->id, $version, $key, $data); + + return Requisition::query()->whereKey($result['id'])->firstOrFail(); + } + + /** + * @param array $input + * @return array + */ + private function execute(User $user, string $action, ?int $id, ?int $version, ?string $key, array $input = []): array + { + try { + return $this->process($user)->command($action, $id, $version, $key, $input); + } catch (RequisitionFailure $error) { + abort_if($error->status !== 422, $error->status, $error->getMessage()); + throw ValidationException::withMessages(['requisition' => $error->getMessage()]); + } + } + + /** @param array $data */ + private function persistDraft(User $user, array $data, ?Requisition $existing = null, ?SupplyRecord $automaticRule = null): Requisition + { + return DB::transaction(function () use ($user, $data, $existing, $automaticRule) { app(ConflictGuard::class)->lockCompany($user); $user = app(CurrentActor::class)->lock($user); - abort_unless(app(OrganizationAccess::class)->canRequest($user), 403); + $executor = $user; + if ($automaticRule !== null) { + abort_unless($existing === null, 403); + $user = app(AutomaticPurchases::class)->requester($executor, $automaticRule); + } else { + abort_unless(app(OrganizationAccess::class)->canRequest($user), 403); + } if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) { throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']); } @@ -39,9 +77,10 @@ class RequisitionWorkflow foreach ($lines as $line) { $line = app(RequisitionCatalogue::class)->line($user, $line, $oldLines); app(EquipmentWorkflow::class)->line($line, (int) $user->department_id, $data['priority']); - $price = BigDecimal::of($line['unit_price'])->multipliedBy(100)->toScale(0)->toInt(); - $lineSubtotal = BigDecimal::of($line['quantity'])->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt(); - $lineTax = BigDecimal::of($lineSubtotal)->multipliedBy($line['tax_rate'])->dividedBy(100, 0, RoundingMode::HalfUp)->toInt(); + $calculation = InternalRequisitions::line($line['quantity'], $line['unit_price'], $line['tax_rate']); + $price = $calculation['unit_price_minor']; + $lineSubtotal = $calculation['subtotal_minor']; + $lineTax = $calculation['tax_minor']; $items[] = [ 'tenant_id' => $user->tenant_id, ...(! empty($line['catalogue_entry_id']) ? ['catalogue_entry_id' => $line['catalogue_entry_id'], 'catalogue_snapshot' => $line['catalogue_snapshot']] : []), @@ -63,6 +102,7 @@ class RequisitionWorkflow 'needed_by' => $data['needed_by'], 'justification' => $data['justification'], 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, ...($existing ? [] : ['comparison_policy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($user->tenant_id))]), + ...(Schema::hasColumn('requisitions', 'process_version') ? ['process_version' => $existing ? (int) $requisition->getAttribute('process_version') + 1 : 1] : []), ])->save(); if (! $existing) { $requisition->update(['number' => sprintf('REQ-%s-%06d', now()->format('Y'), $requisition->id)]); @@ -70,71 +110,29 @@ class RequisitionWorkflow $requisition->items()->delete(); $requisition->items()->createMany($items); $requisition->load('items'); - $this->audit($user, $existing ? 'requisition.updated' : 'requisition.created', $requisition, $before, $requisition->toArray()); + $this->audit($executor, $automaticRule !== null ? 'requisition.automatically_created' : ($existing ? 'requisition.updated' : 'requisition.created'), $requisition, $before, $requisition->toArray() + ($automaticRule === null ? [] : ['automatic_rule_id' => $automaticRule->id, 'designated_requester_id' => $user->id])); return $requisition; }); } - public function submit(User $user, Requisition $requisition): void + /** Only an independently activated rule may create a draft on behalf of its designated requester. */ + public function automaticDraft(User $executor, SupplyRecord $rule, array $data): Requisition { - DB::transaction(function () use ($user, $requisition) { - app(ConflictGuard::class)->lockCompany($user); - $user = app(CurrentActor::class)->lock($user); - abort_unless(app(OrganizationAccess::class)->canRequest($user), 403); - $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); - abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403); - if ($user->department_id !== $requisition->department_id) { - throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']); - } - app(ConflictGuard::class)->assertParticipation($user, $requisition); - foreach ($requisition->items()->get() as $item) { - app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority); - } - app(EquipmentWorkflow::class)->assertTechnical($requisition); - if ($requisition->needed_by < today()) { - throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']); - } - $tenant = Tenant::query()->findOrFail($user->tenant_id); - app(BudgetWorkflow::class)->record($user, $requisition, 'reserve', $requisition->total_minor, $requisition->subtotal_minor); - $amount = $tenant->approval_includes_tax ? $requisition->total_minor : $requisition->subtotal_minor; - $roles = ['department_head']; - if ($amount > $tenant->first_limit_minor) { - $roles[] = 'procurement'; - } - if ($amount > $tenant->second_limit_minor || $requisition->items()->where('purchase_type', 'equipment')->exists()) { - $roles[] = 'finance'; - } - foreach ($roles as $position => $role) { - $requisition->steps()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]); - } - $snapshot = $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']); - $requisition->update(['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $snapshot]); - $this->audit($user, 'requisition.submitted', $requisition, ['status' => 'draft'], ['status' => 'pending', 'rules' => $snapshot, 'roles' => $roles]); - }); + $process = new InternalRequisitions(new InternalRequisitionPort($executor, fn (array $input, ?Requisition $existing) => $this->persistDraft($executor, $input, $existing, $rule), $rule)); + $result = $process->command('save', null, null, null, $data); + + return Requisition::query()->findOrFail($result['id']); } - public function decide(User $user, Requisition $requisition, string $decision, ?string $comment): void + public function submit(User $user, Requisition $requisition, ?string $operationKey = null, ?int $version = null): void { - DB::transaction(function () use ($user, $requisition, $decision, $comment) { - app(ConflictGuard::class)->lockCompany($user); - $user = app(CurrentActor::class)->lock($user); - $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); - $requisition->load('steps'); - abort_unless($requisition->canDecide($user), 403); - if ($decision === 'approved') { - app(EquipmentWorkflow::class)->assertTechnical($requisition); - } - $step = $requisition->steps->firstWhere('decision', 'pending'); - abort_if($step === null, 409); - $step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]); - $status = $decision === 'rejected' ? 'rejected' : ($requisition->steps()->where('decision', 'pending')->exists() ? 'pending' : 'approved'); - $requisition->update(['status' => $status]); - if ($decision === 'rejected') { - app(BudgetWorkflow::class)->record($user, $requisition, 'release'); - } - $this->audit($user, 'approval.'.$decision, $requisition, ['status' => 'pending'], ['status' => $status, 'step_id' => $step->id, 'role' => $step->role, 'comment' => $comment]); - }); + $this->execute($user, 'submit', $requisition->id, $version, $operationKey); + } + + public function decide(User $user, Requisition $requisition, string $decision, ?string $comment, ?string $operationKey = null, ?int $version = null): void + { + $this->execute($user, 'decide', $requisition->id, $version, $operationKey, ['decision' => $decision, 'comment' => $comment]); } /** @param array|null $before diff --git a/app/Services/StockWorkflow.php b/app/Services/StockWorkflow.php index 001791c..0c74ba1 100644 --- a/app/Services/StockWorkflow.php +++ b/app/Services/StockWorkflow.php @@ -8,10 +8,12 @@ use App\Models\SparePart; use App\Models\StockItem; use App\Models\StockMovement; use App\Models\StockOperation; +use App\Models\SupplyRecord; use App\Models\User; use App\Models\Warehouse; use Brick\Math\BigDecimal; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class StockWorkflow @@ -24,15 +26,20 @@ class StockWorkflow return ['quantity' => BigDecimal::of((string) (clone $query)->sum('quantity_milli'))->toInt(), 'version' => (int) $query->max('id')]; } - /** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */ + /** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int,lot_counts?:list>,lot_version?:string} $data */ public function record(User $actor, array $data): StockOperation { - abort_unless($actor->tenant_id !== null && $actor->hasFunctions([PurchasingRole::Warehouse]) && auth()->id() === $actor->id, 403); + if (! in_array($data['kind'], ['issue', 'transfer', 'count', 'adjustment'], true)) { + throw ValidationException::withMessages(['kind' => 'Esta operação exige o seu próprio percurso de preparação e aprovação.']); + } + $roles = $data['kind'] === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]; + abort_unless($actor->tenant_id !== null && $actor->hasFunctions($roles) && auth()->id() === $actor->id, 403); return DB::transaction(function () use ($actor, $data) { // Same first lock as receipts: includes empty balances and concurrent receipts. app(ConflictGuard::class)->lockCompany($actor); - $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]); + $actor = app(CurrentActor::class)->lock($actor); + abort_unless($actor->hasFunctions($data['kind'] === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]), 403); $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); $existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first(); if ($existing) { @@ -61,7 +68,12 @@ class StockWorkflow throw ValidationException::withMessages(['destination_warehouse_id' => 'Escolha outro armazém da mesma empresa.']); } $balance = $this->balance($item->id, $warehouse->id); + $reservations = app(WarehouseReservations::class); + $reservationBefore = $reservations->committed($item->id, $warehouse->id) > 0 ? $reservations->free($item->id, $warehouse->id) : null; $quantity = ReceiptWorkflow::milli($data['quantity']); + if (($data['kind'] === 'count' && $quantity < 0) || (in_array($data['kind'], ['issue', 'transfer'], true) && $quantity <= 0)) { + throw ValidationException::withMessages(['quantity' => 'Use uma quantidade física válida; correcções exigem contagem ou estorno ligado à origem.']); + } $delta = -$quantity; $count = null; if ($data['kind'] === 'count') { @@ -76,13 +88,36 @@ class StockWorkflow } $quantity = $count->quantity_milli; $delta = $quantity - $balance['quantity']; - if ($delta === 0) { + if ($delta === 0 && ! isset($count->snapshot['lot_count'])) { throw ValidationException::withMessages(['count_id' => 'Contagem sem divergência; não há ajuste a efectuar.']); } } if ($balance['quantity'] + $delta < 0) { throw ValidationException::withMessages(['quantity' => 'Stock insuficiente. A operação não pode produzir saldo negativo.']); } + $lotService = app(InventoryLots::class); + $tracked = $lotService->setup($item->id, $warehouse->id) !== null; + $lotCount = $data['kind'] === 'count' ? app(InventoryCounts::class)->prepare($item->id, $warehouse->id, $quantity, $data) : null; + $adjustment = null; + if ($data['kind'] === 'adjustment') { + if ($tracked) { + $adjustment = app(InventoryCounts::class)->adjustment($actor, $count); + } else { + abort_unless($actor->hasFunctions([PurchasingRole::Warehouse]), 403); + } + } + $lotPlan = $data['kind'] === 'issue' ? $lotService->issuePlan($item->id, $warehouse->id, $quantity) : []; + if ($destination !== null) { + $lotPlan = $lotService->transferPlan($item->id, $warehouse->id, $destination->id, $quantity); + } + $lotSnapshot = $lotPlan === [] ? [] : ['lot_partition' => ['plan' => $lotService->partitionSnapshot($lotPlan, $warehouse->id), + 'source_setup_id' => $lotService->setup($item->id, $warehouse->id)?->id, 'destination_setup_id' => $destination === null ? null : $lotService->setup($item->id, $destination->id)?->id]]; + if ($lotCount !== null) { + $lotSnapshot['lot_count'] = $lotCount; + } + if ($adjustment !== null) { + $lotSnapshot['lot_adjustment'] = $adjustment; + } $operation = StockOperation::query()->create([ 'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $responsible->id, 'warehouse_id' => $warehouse->id, 'destination_warehouse_id' => $destination?->id, 'stock_item_id' => $item->id, @@ -90,22 +125,159 @@ class StockWorkflow 'equipment_id' => $equipment?->id, 'spare_part_id' => $part?->id, 'payload_hash' => $hash, 'destination' => $data['destination'] ?? null, 'reason' => $data['reason'], 'quantity_milli' => $quantity, 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'recorded_at' => now(), - 'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $responsible->only(['id', 'name']), 'stock_item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'destination_warehouse' => $destination?->only(['id', 'code', 'name']), 'equipment' => $equipment?->toArray(), 'spare_part' => $part?->toArray(), 'difference_milli' => $quantity - $balance['quantity'], 'budget_checked' => false, 'development_only' => true], + 'snapshot' => [...$lotSnapshot, 'actor' => $actor->only(['id', 'name']), 'responsible' => $responsible->only(['id', 'name']), 'stock_item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'destination_warehouse' => $destination?->only(['id', 'code', 'name']), 'equipment' => $equipment?->toArray(), 'spare_part' => $part?->toArray(), 'difference_milli' => $quantity - $balance['quantity'], 'budget_checked' => false, 'development_only' => true], ]); - if ($delta !== 0) { - $this->movement($actor, $operation, $warehouse->id, $delta, $data['kind'] === 'transfer' ? 'transfer_out' : $data['kind']); + if ($adjustment !== null) { + foreach ([['decrease_milli', -1, 'adjustment_out'], ['increase_milli', 1, 'adjustment_in']] as [$key, $sign, $type]) { + if ($adjustment[$key] > 0) { + $movement = $this->movement($actor, $operation, $warehouse->id, $sign * $adjustment[$key], $type); + $lotService->adjusted($actor, $count, $movement, $adjustment['rows'], $responsible->id); + } + } + if (! $lotService->context($item->id, $warehouse->id)['reconciled']) { + throw ValidationException::withMessages(['count_id' => 'O ajuste não reconciliou a repartição por lotes.']); + } + } elseif ($delta !== 0) { + $movement = $this->movement($actor, $operation, $warehouse->id, $delta, $data['kind'] === 'transfer' ? 'transfer_out' : $data['kind']); + foreach ($lotPlan as $allocation) { + $lotService->allocate($allocation['lot_id'], $warehouse->id, -$allocation['quantity'], $movement); + } if ($destination) { - $this->movement($actor, $operation, $destination->id, $quantity, 'transfer_in'); + $arrival = $this->movement($actor, $operation, $destination->id, $quantity, 'transfer_in'); + if ($lotPlan !== []) { + $lotService->transferred($actor, $arrival, $lotPlan, $responsible->id); + } } } app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray()); + if ($reservationBefore !== null) { + $reservations->protect($item->id, $warehouse->id, $reservationBefore); + } + app(AutomaticPurchases::class)->signal($actor, $item->id, $warehouse->id); + if ($destination !== null) { + app(AutomaticPurchases::class)->signal($actor, $item->id, $destination->id); + } return $operation; }); } - private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): void + /** Executes an approved physical disposition through the same signed ledger. */ + public function disposition(User $actor, SupplyRecord $approved): StockOperation { - StockMovement::query()->create(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'stock_item_id' => $operation->stock_item_id, 'warehouse_id' => $warehouse, 'equipment_id' => $operation->equipment_id, 'spare_part_id' => $operation->spare_part_id, 'moved_by' => $actor->id, 'movement_type' => $type, 'quantity_milli' => $quantity, 'snapshot' => [...$operation->snapshot, 'warehouse' => Warehouse::query()->findOrFail($warehouse)->only(['id', 'code', 'name']), 'reference' => $operation->reference, 'reason' => $operation->reason], 'moved_at' => now()]); + return DB::transaction(function () use ($actor, $approved) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]); + $current = app(SupplyPlanning::class)->latest('stock_event')->where('record_key', $approved->record_key)->firstOrFail(); + if ($current->id !== $approved->id || $current->state !== 'approved' || $actor->id === $current->snapshot['reviewed_by']) { + throw ValidationException::withMessages(['action' => 'A saída exige a aprovação actual e operador independente da decisão.']); + } + $basis = app(InventoryDispositions::class)->basis($current->data); + if ($basis['source'] !== $current->snapshot['source']) { + throw ValidationException::withMessages(['action' => 'A origem mudou. Actualize a preparação.']); + } + $data = $current->data; + $quantity = $data['quantity_milli']; + if (! is_int($quantity) || $quantity < 1) { + throw new \LogicException('A saída física exige quantidade positiva em milésimos exactos.'); + } + if (StockOperation::query()->where('reference', $data['reference'])->exists()) { + throw ValidationException::withMessages(['data.reference' => 'Já existe uma operação de stock com esta referência.']); + } + $balance = $this->balance($data['stock_item_id'], $data['warehouse_id']); + $reservations = app(WarehouseReservations::class); + $reservationBefore = $reservations->committed($data['stock_item_id'], $data['warehouse_id']) > 0 ? $reservations->free($data['stock_item_id'], $data['warehouse_id']) : null; + $snapshot = ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), + 'stock_item' => $basis['source']['item'], 'warehouse' => $basis['source']['warehouse'], 'destination_warehouse' => null, + 'disposition' => ['approved_id' => $current->id, 'record_key' => $current->record_key, 'source' => $basis['source'], 'financial_correction_created' => false], + 'budget_checked' => false]; + $operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, + 'warehouse_id' => $data['warehouse_id'], 'stock_item_id' => $data['stock_item_id'], 'kind' => $data['kind'], 'reference' => $data['reference'], + 'idempotency_key' => $current->record_key, 'payload_hash' => hash('sha256', json_encode([$current->id, $data, $basis['source']], JSON_THROW_ON_ERROR)), + 'quantity_milli' => $data['quantity_milli'], 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'recorded_at' => now(), + 'destination' => Str::limit($basis['source']['receipt']['supplier_name'] ?? 'Abate físico autorizado', 200, ''), 'reason' => $current->reason, 'snapshot' => $snapshot]); + $movement = $this->movement($actor, $operation, $data['warehouse_id'], -$quantity, $data['kind']); + if ($basis['source']['lot'] !== null) { + app(InventoryLots::class)->allocate($data['lot_id'], $data['warehouse_id'], -$quantity, $movement); + if (! app(InventoryLots::class)->context($data['stock_item_id'], $data['warehouse_id'])['reconciled']) { + throw ValidationException::withMessages(['action' => 'A saída não reconciliou o lote.']); + } + } + app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray()); + if ($reservationBefore !== null) { + $reservations->protect($data['stock_item_id'], $data['warehouse_id'], $reservationBefore); + } + app(AutomaticPurchases::class)->signal($actor, $data['stock_item_id'], $data['warehouse_id']); + + return $operation; + }); + } + + /** @return array{operation:StockOperation,lots:list} */ + public function reverse(User $actor, SupplyRecord $approved): array + { + return DB::transaction(function () use ($actor, $approved) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]); + $current = app(SupplyPlanning::class)->latest('stock_reversal')->where('record_key', $approved->record_key)->firstOrFail(); + if ($current->id !== $approved->id || $current->state !== 'approved' || $actor->id === $current->snapshot['reviewed_by']) { + throw ValidationException::withMessages(['action' => 'O estorno exige a aprovação actual e execução independente da decisão.']); + } + $basis = app(InventoryReversals::class)->basis($current->data); + if ($basis['source'] !== $current->snapshot['source'] || StockOperation::query()->where('reference', $current->data['reference'])->exists()) { + throw ValidationException::withMessages(['action' => 'A origem mudou ou a referência já foi utilizada. Actualize a preparação.']); + } + $source = $basis['source']; + $reservations = app(WarehouseReservations::class); + $reservationBefore = []; + foreach ($source['rows'] as $row) { + if ($reservations->committed($source['item']['id'], $row['warehouse_id']) > 0) { + $reservationBefore[$row['warehouse_id']] = $reservations->free($source['item']['id'], $row['warehouse_id']); + } + } + $warehouseId = $source['rows'][0]['warehouse_id']; + $balance = $this->balance($source['item']['id'], $warehouseId); + $gross = 0; + foreach ($source['rows'] as $row) { + $gross = BigDecimal::of($gross)->plus(BigDecimal::of($row['quantity_milli'])->abs())->toInt(); + } + $operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, 'stock_item_id' => $source['item']['id'], + 'equipment_id' => $source['rows'][0]['equipment_id'], 'spare_part_id' => $source['rows'][0]['spare_part_id'], + 'warehouse_id' => $warehouseId, 'kind' => 'reversal', 'reference' => $current->data['reference'], 'idempotency_key' => $current->record_key, + 'payload_hash' => hash('sha256', json_encode([$current->id, $source], JSON_THROW_ON_ERROR)), 'quantity_milli' => $gross, + 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'reason' => $current->reason, 'recorded_at' => now(), + 'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), 'stock_item' => $source['item'], + 'warehouse' => Warehouse::query()->whereKey($warehouseId)->firstOrFail()->only(['id', 'code', 'name']), 'destination_warehouse' => null, 'equipment' => $source['rows'][0]['equipment'], + 'reversal' => ['record_key' => $current->record_key, 'approved_id' => $current->id, 'source' => $source, 'quantity_basis' => 'gross_absolute_movement_quantities', 'financial_correction_created' => false]]]); + $links = []; + foreach ($source['rows'] as $row) { + $quantity = $row['quantity_milli']; + if (! is_int($quantity) || $quantity === 0) { + throw new \LogicException('O estorno exige quantidades inteiras exactas em milésimos.'); + } + $movement = $this->movement($actor, $operation, $row['warehouse_id'], $quantity, $quantity > 0 ? 'reversal_in' : 'reversal_out'); + $links = [...$links, ...app(InventoryLots::class)->reverseMovement($actor, $current, $movement, $row['lots'], $row['source_movement_id'])]; + } + foreach (array_unique(array_column($source['rows'], 'warehouse_id')) as $warehouse) { + $context = app(InventoryLots::class)->context($source['item']['id'], $warehouse); + if ($context !== null && ! $context['reconciled']) { + throw ValidationException::withMessages(['action' => 'O estorno não reconciliou a repartição por lotes.']); + } + } + app(RequisitionWorkflow::class)->audit($actor, 'stock.reversal', null, null, $operation->toArray()); + foreach ($reservationBefore as $location => $before) { + $reservations->protect($source['item']['id'], $location, $before); + } + foreach ($source['rows'] as $row) { + app(AutomaticPurchases::class)->signal($actor, $source['item']['id'], $row['warehouse_id']); + } + + return ['operation' => $operation, 'lots' => $links]; + }); + } + + private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): StockMovement + { + return StockMovement::query()->create(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'stock_item_id' => $operation->stock_item_id, 'warehouse_id' => $warehouse, 'equipment_id' => $operation->equipment_id, 'spare_part_id' => $operation->spare_part_id, 'moved_by' => $actor->id, 'movement_type' => $type, 'quantity_milli' => $quantity, 'snapshot' => [...$operation->snapshot, 'warehouse' => Warehouse::query()->findOrFail($warehouse)->only(['id', 'code', 'name']), 'reference' => $operation->reference, 'reason' => $operation->reason], 'moved_at' => now()]); } } diff --git a/app/Services/SupplyCoordination.php b/app/Services/SupplyCoordination.php new file mode 100644 index 0000000..26b7390 --- /dev/null +++ b/app/Services/SupplyCoordination.php @@ -0,0 +1,297 @@ + $input */ + public function save(User $actor, string $kind, array $input): SupplyRecord + { + abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.'); + + return DB::transaction(function () use ($actor, $kind, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', + 'action' => 'required|in:save,approve,return,suspend,fulfil,cancel', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + $permission = match ($kind) { + 'alternative' => $v['action'] === 'save' ? 'supply_technical_prepare' : 'supply_technical_review', + 'reservation' => 'supply_reserve', 'delivery' => 'supply_delivery', + default => abort(404), + }; + abort_unless(app(SupplyPlanning::class)->allows($actor, $permission), 403); + $hash = hash('sha256', json_encode([$kind, $v], JSON_THROW_ON_ERROR)); + $retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first(); + if ($retry) { + $this->require($retry->input_hash === $hash, 'operation_key', 'Chave já usada com outro conteúdo.'); + + return $retry; + } + $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.'); + $data = $old->data ?? []; + $snapshot = $old->snapshot ?? []; + $state = $old->state ?? 'draft'; + if ($v['action'] === 'save') { + $openCorrected = $kind === 'reservation' && $old !== null && $state === 'fulfilled' && $this->remainingReservation($old) > 0; + $this->require($kind === 'alternative' ? $old === null || in_array($state, ['draft', 'returned'], true) : $old === null || $state === 'active' || $openCorrected, 'action', 'O registo já está encerrado ou aprovado.'); + $data = $this->prepare($actor, $kind, $v['data'], $old); + $stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail(); + $warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail(); + $snapshot = ['item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), + 'contributors' => array_values(array_unique([...($snapshot['contributors'] ?? []), $actor->id]))]; + if ($kind === 'alternative') { + $substitute = StockItem::query()->whereKey($data['substitute_id'])->firstOrFail(); + $this->require($substitute->id !== $stock->id, 'data.substitute_id', 'O artigo não pode substituir-se a si próprio.'); + $snapshot['substitute'] = $substitute->only(['id', 'code', 'name', 'unit']); + } + if ($kind === 'delivery') { + $line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail(); + $order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail(); + $snapshot['order'] = $order->only(['id', 'number']); + $snapshot['order_line'] = $line->only(['id', 'quantity', 'unit', 'description']); + } + $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($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.'); + $this->require($data['valid_until'] >= today()->toDateString(), 'action', 'A aprovação não pode nascer expirada.'); + foreach (['item' => $data['stock_item_id'], 'substitute' => $data['substitute_id']] as $key => $id) { + $this->require(StockItem::query()->whereKey($id)->firstOrFail()->only(['id', 'code', 'name', 'unit']) === $snapshot[$key], 'action', 'As especificações de cadastro mudaram. Devolva e reveja a preparação.'); + } + $duplicate = app(SupplyPlanning::class)->latest('alternative')->where('state', 'approved')->where('stock_item_id', $old->stock_item_id)->where('warehouse_id', $old->warehouse_id) + ->where('record_key', '!=', $old->record_key)->get()->contains(fn ($r) => $r->data['substitute_id'] === $data['substitute_id'] && $r->data['application'] === $data['application'] && $r->data['valid_until'] >= today()->toDateString()); + $this->require(! $duplicate, 'action', 'Já existe relação técnica aprovada neste sentido, aplicação e local.'); + } + $state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$v['action']]; + } elseif ($v['action'] === 'fulfil' && $kind === 'reservation') { + $this->require($old !== null && in_array($state, ['active', 'fulfilled'], true) && $this->remainingReservation($old) > 0, 'action', 'A reserva não tem quantidade pendente.'); + $f = Validator::make($v['data'], ['stock_operation_id' => 'required|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000'])->validate(); + $f['quantity_milli'] = (int) $f['quantity_milli']; + $operation = StockOperation::query()->whereKey($f['stock_operation_id'])->firstOrFail(); + $this->require($operation->kind === 'issue' && $operation->stock_item_id === $old->stock_item_id && $operation->warehouse_id === $old->warehouse_id, 'data.stock_operation_id', 'Seleccione uma saída efectiva deste artigo/local.'); + $this->require(! app(InventoryReversals::class)->operationReversed($operation->id), 'data.stock_operation_id', 'A saída foi estornada e não pode satisfazer uma reserva.'); + $used = app(SupplyPlanning::class)->latest('reservation')->get()->sum(fn ($r) => array_sum(array_column(array_filter($r->data['applications'] ?? [], fn ($a) => $a['operation_id'] === $operation->id), 'quantity_milli'))); + $remaining = $this->remainingReservation($old); + $this->require($f['quantity_milli'] <= $remaining && $used + $f['quantity_milli'] <= $operation->quantity_milli, 'data.quantity_milli', 'A afectação excede a reserva pendente ou a saída ainda disponível.'); + $data['applications'][] = ['operation_id' => $operation->id, 'quantity_milli' => (int) $f['quantity_milli'], 'reference' => $operation->reference]; + $state = $f['quantity_milli'] === $remaining ? 'fulfilled' : 'active'; + } elseif ($v['action'] === 'cancel') { + $this->require($old !== null && ($state === 'active' || ($kind === 'reservation' && $state === 'fulfilled' && $this->remainingReservation($old) > 0)), 'action', 'O registo não está activo.'); + $state = 'cancelled'; + } else { + throw ValidationException::withMessages(['action' => 'Acção incompatível com a responsabilidade ou registo.']); + } + if ($kind === 'reservation') { + $corrections = app(InventoryReversals::class)->corrections('operation'); + $correctedApplications = array_values(array_filter(array_column($data['applications'], 'operation_id'), fn ($id) => isset($corrections[$id]))); + if ($correctedApplications !== []) { + $snapshot['reversed_operations'] = array_values(array_unique($correctedApplications)); + $snapshot['effective_allocated_milli'] = array_sum(array_column($this->effectiveApplications($data['applications']), 'quantity_milli')); + } + } + $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old->record_key ?? (string) Str::uuid(), + 'version' => ($old->version ?? 0) + 1, 'state' => $state, 'stock_item_id' => $data['stock_item_id'], 'warehouse_id' => $data['warehouse_id'], + 'purchase_order_id' => $snapshot['order']['id'] ?? null, 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, + 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'reason' => $v['reason'], 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'supply.'.$kind.'.'.$v['action'], null, $old?->toArray(), $record->toArray()); + + return $record; + }); + } + + /** @param array $input + * @return array + */ + private function prepare(User $actor, string $kind, array $input, ?SupplyRecord $old): array + { + $rules = ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1']; + $rules += match ($kind) { + 'alternative' => ['substitute_id' => 'required|integer|min:1', 'application' => 'required|string|max:200', 'compatibility' => 'required|in:equivalent,conditional,incompatible,pending', + 'conditions' => 'required_if:compatibility,conditional|nullable|string|max:4000', 'evidence' => 'required|string|min:10|max:4000', 'factor_numerator' => 'required|integer|min:1|max:1000000', 'factor_denominator' => 'required|integer|min:1|max:1000000', 'valid_until' => 'required|date_format:Y-m-d'], + 'reservation' => ['requisition_item_id' => 'nullable|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000', 'needed_on' => 'required|date_format:Y-m-d', 'additional' => 'required|boolean', 'reference' => 'required|string|max:200'], + 'delivery' => ['purchase_order_item_id' => 'required|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000', 'expected_on' => 'required|date_format:Y-m-d', 'confirmed_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:5|max:2000'], + default => abort(404), + }; + $data = Validator::make($input, $rules)->validate(); + foreach (['stock_item_id', 'warehouse_id', 'substitute_id', 'factor_numerator', 'factor_denominator', 'quantity_milli', 'requisition_item_id', 'purchase_order_item_id'] as $field) { + if (isset($data[$field]) && $data[$field] !== '') { + $data[$field] = (int) $data[$field]; + } + } + if (isset($data['additional'])) { + $data['additional'] = (bool) $data['additional']; + } + if ($old !== null) { + $this->require((int) $data['stock_item_id'] === $old->stock_item_id && (int) $data['warehouse_id'] === $old->warehouse_id, 'data.stock_item_id', 'A revisão mantém o artigo e local originais.'); + } + if ($kind === 'reservation') { + $this->require($old === null || ($old->data['requisition_item_id'] ?? null) == ($data['requisition_item_id'] ?? null), 'data.requisition_item_id', 'A revisão conserva a necessidade de origem.'); + $data['applications'] = $old?->data['applications'] ?? []; + $allocated = array_sum(array_column($this->effectiveApplications($data['applications']), 'quantity_milli')); + $this->require($data['quantity_milli'] >= $allocated, 'data.quantity_milli', 'A reserva não pode ficar abaixo das saídas já afectadas.'); + if (! empty($data['requisition_item_id'])) { + $line = RequisitionItem::query()->whereKey($data['requisition_item_id'])->firstOrFail(); + $request = Requisition::query()->whereKey($line->requisition_id)->firstOrFail(); + $this->require($request->status === 'approved' && ($line->catalogue_snapshot['stock_item_id'] ?? null) === (int) $data['stock_item_id'], 'data.requisition_item_id', 'Seleccione uma linha aprovada ligada a este artigo do catálogo.'); + $data['reference'] = $request->number.' / '.$line->id; + $reserved = app(SupplyPlanning::class)->latest('reservation')->where('id', '!=', $old->id ?? 0)->get()->filter(fn ($r) => ($r->data['requisition_item_id'] ?? null) == $line->id) + ->sum(fn ($r) => $r->state === 'cancelled' ? array_sum(array_column($this->effectiveApplications($r->data['applications']), 'quantity_milli')) : $r->data['quantity_milli']); + $this->require($reserved + $data['quantity_milli'] <= ReceiptWorkflow::milli($line->quantity), 'data.quantity_milli', 'As reservas desta linha excedem a necessidade aprovada.'); + } + $duplicate = app(SupplyPlanning::class)->latest('reservation')->whereIn('state', ['active', 'fulfilled'])->where('id', '!=', $old->id ?? 0)->where('stock_item_id', $data['stock_item_id'])->where('warehouse_id', $data['warehouse_id'])->get()->contains(fn ($r) => $r->data['reference'] === $data['reference'] && ($r->state === 'active' || $this->remainingReservation($r) > 0)); + $this->require(! $duplicate, 'data.reference', 'Esta necessidade já tem reserva activa. Consulte e reveja a reserva existente.'); + } + if ($kind === 'delivery') { + $line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail(); + $order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail(); + $stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail(); + $linked = $order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null; + $this->require($order->status === 'issued' && $line->unit === $stock->unit && ($linked === null || $linked === $stock->id), 'data.stock_item_id', 'A confirmação deve respeitar o artigo e unidade da ordem emitida.'); + $this->require($old === null || $old->data['purchase_order_item_id'] === (int) $data['purchase_order_item_id'], 'data.purchase_order_item_id', 'A revisão mantém a linha da ordem original.'); + $others = app(SupplyPlanning::class)->latest('delivery')->where('state', 'active')->where('id', '!=', $old->id ?? 0)->get()->filter(fn ($r) => $r->data['purchase_order_item_id'] == $line->id)->sum(fn ($r) => $r->data['quantity_milli']); + $this->require($others === 0, 'data.purchase_order_item_id', 'Esta linha já tem confirmação activa. Reveja-a para actualizar quantidade ou prazo.'); + $this->require($others + $data['quantity_milli'] <= ReceiptWorkflow::milli($line->quantity), 'data.quantity_milli', 'As confirmações excedem a quantidade da ordem. Reveja a confirmação existente.'); + $this->require($data['quantity_milli'] >= app(ReceiptWorkflow::class)->recordedQuantity($line->id, 'received_milli'), 'data.quantity_milli', 'A quantidade acumulada confirmada não pode ficar abaixo da já recebida.'); + // A line has one canonical destination/article mapping, even across revisions. + $mapping = app(SupplyPlanning::class)->latest('delivery')->get()->first(fn ($r) => $r->data['purchase_order_item_id'] == $line->id); + $this->require($mapping === null || ($mapping->stock_item_id === $stock->id && $mapping->warehouse_id === (int) $data['warehouse_id']), 'data.warehouse_id', 'Esta linha já tem artigo/local associados. Reutilize a associação existente.'); + $receivedStocks = GoodsReceiptItem::query()->where('purchase_order_item_id', $line->id)->whereNotNull('stock_item_id')->pluck('stock_item_id')->unique(); + $this->require($receivedStocks->isEmpty() || ($receivedStocks->count() === 1 && (int) $receivedStocks->first() === $stock->id), 'data.stock_item_id', 'A confirmação deve reutilizar o artigo já recebido nesta linha.'); + $differentWarehouse = GoodsReceipt::query()->whereIn('id', GoodsReceiptItem::query()->where('purchase_order_item_id', $line->id)->select('goods_receipt_id'))->where('warehouse_id', '!=', $data['warehouse_id'])->exists(); + $this->require(! $differentWarehouse, 'data.warehouse_id', 'Reutilize o armazém em que a linha já foi recebida.'); + } + + return $data; + } + + public function eligibleAlternative(SupplyRecord $record): bool + { + if ($record->kind !== 'alternative' || $record->state !== 'approved' || $record->data['compatibility'] !== 'equivalent' || $record->data['valid_until'] < today()->toDateString()) { + return false; + } + foreach (['item' => $record->stock_item_id, 'substitute' => $record->data['substitute_id']] as $key => $id) { + if (StockItem::query()->whereKey($id)->first()?->only(['id', 'code', 'name', 'unit']) !== $record->snapshot[$key]) { + return false; + } + } + + return true; + } + + public function remainingDelivery(SupplyRecord $record): int + { + $received = app(ReceiptWorkflow::class)->recordedQuantity($record->data['purchase_order_item_id'], 'received_milli'); + + return max(0, BigDecimal::of($record->data['quantity_milli'])->minus($received)->toInt()); + } + + /** @param list> $applications + * @return list> + */ + public function effectiveApplications(array $applications): array + { + $reversed = app(InventoryReversals::class)->corrections('operation'); + + return array_values(array_filter($applications, fn ($a) => ! isset($reversed[$a['operation_id']]))); + } + + public function remainingReservation(SupplyRecord $record): int + { + return BigDecimal::of($record->data['quantity_milli'])->minus(array_sum(array_column($this->effectiveApplications($record->data['applications']), 'quantity_milli')))->toInt(); + } + + /** @return array{reservations:list,receipts:list,warnings:list,consumed_today:int,fulfilled_needs:list} */ + public function events(int $stock, int $warehouse, string $today): array + { + $planning = app(SupplyPlanning::class); + $reservations = $planning->latest('reservation')->whereIn('state', ['active', 'fulfilled'])->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get()->filter(fn ($r) => $r->state === 'active' || $this->remainingReservation($r) > 0)->map(fn ($r) => [ + 'date' => max($today, $this->dateField($r, 'needed_on')), 'quantity' => $this->remainingReservation($r), 'additional' => $r->data['additional'] === true || $this->dateField($r, 'needed_on') < $today, 'record_key' => $r->record_key, 'version' => $r->version])->values()->all(); + $receipts = []; + $warnings = []; + $deliveries = $planning->latest('delivery')->where('state', 'active')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get(); + foreach ($deliveries as $r) { + $remaining = $this->remainingDelivery($r); + if ($remaining === 0) { + continue; + } + $import = $planning->latest('import')->where('purchase_order_id', $r->purchase_order_id)->first(); + if ($r->data['expected_on'] < $today || ($import && $import->state !== 'released')) { + $warnings[] = 'Entrega '.$r->snapshot['order']['number'].' sem disponibilidade utilizável confirmada; rever atraso ou libertação.'; + + continue; + } + $receipts[] = ['date' => $this->dateField($r, 'expected_on'), 'quantity' => $remaining, 'confirmed' => true, 'record_key' => $r->record_key, 'version' => $r->version]; + } + + $corrections = app(InventoryReversals::class)->corrections('operation'); + $issues = StockOperation::query()->where('kind', 'issue')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->whereDate('recorded_at', $today)->get()->reject(fn ($op) => isset($corrections[$op->id])); + $allReservations = $planning->latest('reservation')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get(); + $operationIds = []; + foreach ($allReservations as $reservation) { + foreach ($this->effectiveApplications($reservation->data['applications']) as $application) { + $operationIds[] = $application['operation_id']; + } + } + $sources = StockOperation::query()->whereIn('id', $operationIds)->get()->keyBy('id'); + $allocatedToday = 0; + $fulfilledNeeds = []; + foreach ($allReservations as $reservation) { + foreach ($this->effectiveApplications($reservation->data['applications']) as $application) { + $source = $sources->get($application['operation_id']); + if ($source === null) { + throw new \LogicException('Saída de origem da reserva não encontrada.'); + } + $date = $source->recorded_at->toDateString(); + $applied = BigDecimal::of($application['quantity_milli'])->toInt(); + if ($date === $today) { + $allocatedToday += $applied; + } + $needed = max($date, $this->dateField($reservation, 'needed_on')); + if ($reservation->data['additional'] === false && $needed >= $today) { + $fulfilledNeeds[] = ['date' => $needed, 'quantity' => $applied]; + } + } + } + + return ['reservations' => array_values($reservations), 'receipts' => $receipts, 'warnings' => $warnings, 'consumed_today' => max(0, BigDecimal::of($issues->sum('quantity_milli'))->minus($allocatedToday)->toInt()), 'fulfilled_needs' => $fulfilledNeeds]; + } + + private function dateField(SupplyRecord $record, string $field): string + { + $value = $record->data[$field] ?? null; + if (! is_string($value)) { + throw new \LogicException('Data inválida no histórico de fornecimento.'); + } + + return $value; + } + + private function require(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/SupplyHistory.php b/app/Services/SupplyHistory.php new file mode 100644 index 0000000..1918200 --- /dev/null +++ b/app/Services/SupplyHistory.php @@ -0,0 +1,208 @@ + $input */ + public function save(User $actor, array $input): SupplyRecord + { + abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.'); + + return DB::transaction(function () use ($actor, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', + 'action' => 'required|in:save,approve,return,suspend', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + abort_unless(app(SupplyPlanning::class)->allows($actor, $v['action'] === 'save' ? 'supply_history_prepare' : 'supply_history_review'), 403); + $hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR)); + $retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first(); + if ($retry !== null) { + $this->require($retry->kind === 'history' && $retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.'); + + return $retry; + } + $old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest('history')->where('record_key', $v['record_key'])->firstOrFail() : null; + $this->require((int) $v['version'] === ($old->version ?? 0), 'version', 'A análise mudou. Actualize antes de continuar.'); + if ($v['action'] === 'save') { + $this->require($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Preserve a análise aprovada; prepare outra ou suspenda-a.'); + $data = Validator::make($v['data'], ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1', 'purpose' => 'required|in:resale,internal', + 'period_from' => 'required|date_format:Y-m-d|before:today', 'period_until' => 'required|date_format:Y-m-d|after_or_equal:period_from|before:today', + 'quality' => 'required|in:complete,incomplete,stockout,unknown', 'pattern' => 'required|in:regular,seasonal,intermittent,unknown'])->validate(); + $data['stock_item_id'] = (int) $data['stock_item_id']; + $data['warehouse_id'] = (int) $data['warehouse_id']; + if ($old !== null) { + $this->require($data['stock_item_id'] === $old->stock_item_id && $data['warehouse_id'] === $old->warehouse_id && $data['purpose'] === $old->data['purpose'], 'data.stock_item_id', 'A revisão conserva o artigo, local e finalidade da análise.'); + } + $stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail(); + $warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail(); + $analysis = $this->analyse($stock->id, $warehouse->id, $data['period_from'], $data['period_until']); + $snapshot = ['item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'analysis' => $analysis, + 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id]))]; + $state = 'draft'; + } else { + $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($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(); + $data['scope_confirmed'] = (bool) $confirm['confirm_scope']; + $current = $this->analyse($old->stock_item_id, $old->warehouse_id, $data['period_from'], $data['period_until']); + $this->require($current['source_hash'] === $snapshot['analysis']['source_hash'], 'action', 'O razão histórico mudou. Devolva e actualize a análise.'); + $stock = StockItem::query()->whereKey($old->stock_item_id)->firstOrFail(); + $this->require($stock->only(['id', 'code', 'name', 'unit']) === $snapshot['item'], 'action', 'O cadastro do artigo mudou. Devolva e actualize a análise.'); + $duplicate = app(SupplyPlanning::class)->latest('history')->where('state', 'approved')->where('stock_item_id', $old->stock_item_id)->where('warehouse_id', $old->warehouse_id)->where('id', '!=', $old->id) + ->get()->contains(fn ($r) => $r->data['purpose'] === $data['purpose'] && $r->data['period_from'] === $data['period_from'] && $r->data['period_until'] === $data['period_until']); + $this->require(! $duplicate, 'action', 'Já existe análise aprovada deste período e finalidade. Suspenda a anterior para a substituir.'); + } + $state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$v['action']]; + } + $snapshot['actor'] = $actor->only(['id', 'name']); + $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'history', 'record_key' => $old->record_key ?? (string) Str::uuid(), + 'version' => ($old->version ?? 0) + 1, 'state' => $state, 'stock_item_id' => $data['stock_item_id'], 'warehouse_id' => $data['warehouse_id'], 'created_by' => $actor->id, + 'data' => $data, 'snapshot' => $snapshot, 'reason' => $v['reason'], 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'supply.history.'.$v['action'], null, $old?->toArray(), $record->toArray()); + + return $record; + }); + } + + /** @return array */ + public function analyse(int $stock, int $warehouse, string $from, string $until): array + { + StockItem::query()->whereKey($stock)->firstOrFail(); + Warehouse::query()->whereKey($warehouse)->firstOrFail(); + $start = CarbonImmutable::parse($from)->startOfDay(); + $end = CarbonImmutable::parse($until)->startOfDay(); + $days = (int) $start->diffInDays($end) + 1; + $this->require($days >= 1 && $days <= 3660, 'data.period_until', 'O período deve ter entre 1 e 3660 dias corridos.'); + $series = []; + for ($day = $start; $day <= $end; $day = $day->addDay()) { + $series[$day->toDateString()] = ['date' => $day->toDateString(), 'consumption_milli' => 0, 'delta_milli' => 0]; + } + $opening = BigDecimal::zero(); + $hash = hash_init('sha256'); + $counts = []; + $invalid = false; + $movementCount = 0; + $operationCorrections = app(InventoryReversals::class)->corrections('operation'); + $receiptCorrections = app(InventoryReversals::class)->corrections('receipt'); + foreach (StockMovement::query()->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->whereDate('moved_at', '<=', $until)->orderBy('moved_at')->orderBy('id')->cursor() as $movement) { + $date = $movement->moved_at->toDateString(); + $quantity = BigDecimal::of($movement->quantity_milli); + hash_update($hash, json_encode([$movement->id, $movement->movement_type, (string) $quantity, $movement->moved_at->format('Y-m-d H:i:s.uP'), $movement->stock_operation_id, $movement->goods_receipt_item_id], JSON_THROW_ON_ERROR)."\n"); + $correction = $operationCorrections[$movement->stock_operation_id] ?? $receiptCorrections[$movement->goods_receipt_item_id] ?? null; + if ($correction !== null) { + hash_update($hash, json_encode(['stock_reversal', $correction->id, $correction->record_key, $correction->version], JSON_THROW_ON_ERROR)."\n"); + } + if ($date < $from) { + $opening = $opening->plus($quantity); + + continue; + } + $movementCount++; + $counts[$movement->movement_type] = ($counts[$movement->movement_type] ?? 0) + 1; + if (! isset($series[$date])) { + throw new \LogicException('A data do movimento está fora do período de análise.'); + } + $entry = $series[$date]; + $consumption = $entry['consumption_milli']; + if ($movement->movement_type === 'issue' && $correction === null) { + if ($quantity->isGreaterThanOrEqualTo(0)) { + $invalid = true; + } else { + $consumption = BigDecimal::of($consumption)->minus($quantity)->toInt(); + } + } + $series[$date] = ['date' => $date, 'consumption_milli' => $consumption, 'delta_milli' => BigDecimal::of($entry['delta_milli'])->plus($quantity)->toInt()]; + } + $total = BigDecimal::zero(); + $balance = $opening; + $zeroDays = 0; + $negativeDays = 0; + $activeDays = 0; + foreach ($series as &$day) { + $balance = $balance->plus($day['delta_milli']); + $day['balance_milli'] = $balance->toInt(); + $total = $total->plus($day['consumption_milli']); + $activeDays += $day['consumption_milli'] > 0 ? 1 : 0; + $zeroDays += $balance->isZero() ? 1 : 0; + $negativeDays += $balance->isLessThan(0) ? 1 : 0; + } + unset($day); + + return ['days' => $days, 'total_milli' => $total->toInt(), 'daily_mean_milli' => (string) $total->dividedBy($days, 6, RoundingMode::HalfUp), + 'active_days' => $activeDays, 'zero_balance_days' => $zeroDays, 'negative_balance_days' => $negativeDays, 'invalid_issue' => $invalid, + 'opening_milli' => $opening->toInt(), 'movement_count' => $movementCount, 'movement_types' => $counts, 'series' => array_values($series), 'source_hash' => hash_final($hash), + 'convention' => 'Dias corridos completos; apenas saídas de consumo (issue) entram na média. Transferências, ajustes, contagens e recepções não são consumo.']; + } + + /** @param array $data + * @return array + */ + public function reference(array $data): array + { + $history = app(SupplyPlanning::class)->latest('history')->where('record_key', $data['history_key'])->firstOrFail(); + $result = ['available' => false, 'key' => $history->record_key, 'version' => $history->version, 'unit' => $history->snapshot['item']['unit'], 'period_from' => $history->data['period_from'], 'period_until' => $history->data['period_until'], 'analysis' => $history->snapshot['analysis']]; + $reason = null; + if ($history->stock_item_id !== (int) $data['stock_item_id'] || $history->warehouse_id !== (int) $data['warehouse_id'] || $history->data['purpose'] !== $data['purpose']) { + $reason = 'O histórico deve corresponder ao artigo, local e finalidade da política.'; + } elseif ($history->state !== 'approved' || (isset($data['history_version']) && $history->version !== $data['history_version'])) { + $reason = 'A análise mudou ou deixou de estar aprovada. Seleccione uma versão conferida.'; + } elseif ($history->data['quality'] !== 'complete' || $history->data['pattern'] !== 'regular') { + $reason = 'Cobertura incompleta, ruptura ou padrão sazonal/intermitente: use planeamento manual até existir método validado.'; + } elseif ($history->snapshot['analysis']['days'] < $data['history_min_days']) { + $reason = 'O período é inferior ao mínimo de dias configurado na política.'; + } elseif ($history->data['period_until'] < CarbonImmutable::today()->subDays($data['history_max_age_days'])->toDateString()) { + $reason = 'O período ultrapassa a idade máxima configurada na política.'; + } elseif ($history->snapshot['analysis']['opening_milli'] < 0 || $history->snapshot['analysis']['negative_balance_days'] > 0 || $history->snapshot['analysis']['invalid_issue']) { + $reason = 'Existem inconsistências no razão; reconcilie-as antes de utilizar a média.'; + } else { + $stock = StockItem::query()->whereKey($history->stock_item_id)->firstOrFail(); + $current = $this->analyse($history->stock_item_id, $history->warehouse_id, $history->data['period_from'], $history->data['period_until']); + if ($stock->only(['id', 'code', 'name', 'unit']) !== $history->snapshot['item'] || $current['source_hash'] !== $history->snapshot['analysis']['source_hash']) { + $reason = 'O cadastro ou os movimentos históricos mudaram. Reavalie o período antes de reutilizá-lo.'; + } + } + + return [...$result, 'available' => $reason === null, 'reason' => $reason]; + } + + /** @return array{data:array,history:array|null} */ + public function resolve(SupplyRecord $policy): array + { + if (($policy->data['forecast_method'] ?? 'manual') !== 'historical') { + return ['data' => $policy->data, 'history' => null]; + } + $history = $this->reference([...$policy->data, 'stock_item_id' => $policy->stock_item_id, 'warehouse_id' => $policy->warehouse_id]); + $effective = [...$policy->data, 'daily_milli' => null]; + if ($history['available']) { + $effective['daily_rate'] = ['numerator_milli' => $history['analysis']['total_milli'], 'denominator_days' => $history['analysis']['days']]; + } + + return ['data' => $effective, 'history' => $history]; + } + + private function require(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/SupplyPlanning.php b/app/Services/SupplyPlanning.php new file mode 100644 index 0000000..c7ee695 --- /dev/null +++ b/app/Services/SupplyPlanning.php @@ -0,0 +1,237 @@ +hasFunctions($functions) && ! app(OrganizationAccess::class)->maintenanceOnly($actor); + } + + /** @return Builder */ + public function latest(string $kind) + { + return SupplyRecord::query()->where('kind', $kind)->whereIn('id', SupplyRecord::query()->selectRaw('MAX(id)')->where('kind', $kind)->groupBy('record_key')); + } + + /** Live calendar: physical risk can remain known when a contractual lead time needs reconfirmation. + * @return array + */ + public function calendar(SupplyRecord $policy, string $date): array + { + $stock = StockItem::query()->whereKey($policy->stock_item_id)->first(); + if ($stock === null || $stock->only(['id', 'code', 'name', 'unit']) !== ($policy->snapshot['item'] ?? null)) { + return ['available' => false, 'reason' => 'O cadastro do artigo mudou ou está incompleto. Reveja a política por referência à versão actual antes de interpretar quantidades.']; + } + $balance = app(StockWorkflow::class)->balance($policy->stock_item_id, $policy->warehouse_id); + $events = app(SupplyCoordination::class)->events($policy->stock_item_id, $policy->warehouse_id, $date); + $forecast = app(SupplyHistory::class)->resolve($policy); + $projection = [...app(InventoryLots::class)->project($policy, $forecast['data'], $balance, $events, $date), 'events' => $events, 'forecast_history' => $forecast['history']]; + if (! empty($policy->data['agreement_key'])) { + $agreement = $this->latest('agreement')->where('record_key', $policy->data['agreement_key'])->first(); + $supplier = $agreement === null ? null : Supplier::query()->whereKey($agreement->supplier_id)->first(); + $valid = $agreement !== null && $agreement->state === 'approved' && $agreement->version === ($policy->data['agreement_version'] ?? null) + && $agreement->stock_item_id === $policy->stock_item_id && $agreement->warehouse_id === $policy->warehouse_id + && $agreement->data['valid_from'] <= $date && $agreement->data['valid_until'] >= $date && $supplier !== null && $supplier->eligible() + && $supplier->only(['id', 'name', 'version']) === $agreement->snapshot['supplier']; + if (! $valid) { + $projection = [...$projection, 'lead_time_available' => false, 'lead_time_issue' => 'O acordo, fornecedor ou validação documental deixou de sustentar o prazo aprovado. Reconfirme o fornecimento; as datas de pedir/encomendar ficam por determinar.', + 'request_date' => null, 'order_date' => null, 'reorder_point_milli' => null]; + } + } + + return $projection; + } + + /** @param array $input */ + public function save(User $actor, string $kind, array $input): SupplyRecord + { + if ($kind === 'history') { + return app(SupplyHistory::class)->save($actor, $input); + } + if ($kind === 'suggestion') { + return app(SupplySuggestions::class)->save($actor, $input); + } + if (in_array($kind, ['reservation', 'delivery', 'alternative'], true)) { + return app(SupplyCoordination::class)->save($actor, $kind, $input); + } + abort_unless(in_array($kind, ['policy', 'agreement', 'import'], true), 404); + abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.'); + + return DB::transaction(function () use ($actor, $kind, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $envelope = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', + 'action' => 'required|in:save,approve,return,suspend,transit,arrive,hold,release', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + $action = $envelope['action']; + $permission = $kind === 'import' ? (in_array($action, ['hold', 'release'], true) ? 'import_customs' : 'import_prepare') + : (in_array($action, ['approve', 'return', 'suspend'], true) ? 'supply_review' : 'supply_prepare'); + abort_unless($this->allows($actor, $permission), 403); + $hash = hash('sha256', json_encode([$kind, $envelope], JSON_THROW_ON_ERROR)); + $retry = SupplyRecord::query()->where('operation_key', $envelope['operation_key'])->first(); + if ($retry !== null) { + $this->check($retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.'); + + return $retry; + } + $old = isset($envelope['record_key']) ? SupplyRecord::query()->where('kind', $kind)->where('record_key', $envelope['record_key'])->latest('version')->firstOrFail() : null; + $this->check((int) $envelope['version'] === (int) ($old->version ?? 0), 'version', 'O registo mudou. Actualize antes de continuar.'); + $state = 'draft'; + if ($action === 'save') { + $this->check($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Crie um novo acordo ou política; preserve a versão já aprovada.'); + $data = $this->validateData($kind, $envelope['data']); + if ($kind === 'policy' && ($data['forecast_method'] ?? 'manual') === 'historical') { + $history = app(SupplyHistory::class)->reference($data); + $this->check($history['available'], 'data.history_key', $history['reason'] ?? 'Histórico não disponível.'); + $data['history_version'] = $history['version']; + } + if ($kind === 'policy' && ! empty($data['agreement_key'])) { + $agreement = $this->latest('agreement')->where('record_key', $data['agreement_key'])->firstOrFail(); + $this->check($agreement->state === 'approved' && $agreement->stock_item_id === (int) $data['stock_item_id'] && $agreement->warehouse_id === (int) $data['warehouse_id'], 'data.agreement_key', 'Seleccione um acordo aprovado deste artigo/local.'); + $this->check($agreement->data['lead_days'] !== null && $agreement->data['valid_from'] <= today()->toDateString() && $agreement->data['valid_until'] >= today()->toDateString(), 'data.agreement_key', 'O acordo deve estar vigente e ter prazo conhecido.'); + $data['lead_days'] = $agreement->data['lead_days']; + $data['origin'] = $agreement->data['origin']; + $data['agreement_version'] = $agreement->version; + } + } else { + $this->check($old !== null, 'action', 'Guarde primeiro a preparação.'); + $data = $old->data; + if ($kind !== 'import') { + $this->check(in_array($action, ['approve', 'return', 'suspend'], true), 'action', 'Acção incompatível com este registo.'); + $this->check(! in_array($actor->id, $old->snapshot['contributors'] ?? [$old->created_by], true), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.'); + $this->check($action === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.'); + $state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$action]; + } else { + $this->check(in_array($action, ['transit', 'arrive', 'hold', 'release'], true), 'action', 'Acção incompatível com a importação.'); + $allowed = ['transit' => ['draft'], 'arrive' => ['transit'], 'hold' => ['draft', 'transit', 'arrived', 'released'], 'release' => ['arrived', 'held']]; + $this->check(in_array($old->state, $allowed[$action], true), 'action', 'O marco anterior ainda não foi registado.'); + $event = Validator::make($envelope['data'], ['evidence' => 'required|string|min:5|max:2000', 'event_date' => 'required|date_format:Y-m-d|before_or_equal:today'])->validate(); + $this->check($event['event_date'] >= ($old->data['event']['event_date'] ?? substr($old->created_at->toDateString(), 0, 10)), 'data.event_date', 'O marco não pode anteceder o registo anterior.'); + if ($action === 'release') { + $this->check(! in_array($actor->id, $old->snapshot['contributors'] ?? [$old->created_by], true), 'action', 'A libertação exige conferência independente da preparação.'); + } + $data['event'] = $event; + $state = ['transit' => 'transit', 'arrive' => 'arrived', 'hold' => 'held', 'release' => 'released'][$action]; + } + } + $item = isset($data['stock_item_id']) ? StockItem::query()->whereKey($data['stock_item_id'])->lockForUpdate()->firstOrFail() : null; + $warehouse = isset($data['warehouse_id']) ? Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail() : null; + $supplier = isset($data['supplier_id']) ? Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail() : null; + $order = isset($data['purchase_order_id']) ? PurchaseOrder::query()->whereKey($data['purchase_order_id'])->firstOrFail() : null; + if ($kind === 'agreement' && $state === 'approved') { + $this->check($supplier !== null && $supplier->eligible(), 'supplier_id', 'O fornecedor deve estar activo e com revisão documental vigente.'); + $this->check(! app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, null), 'supplier_id', 'Existe conflito declarado com este fornecedor.'); + $this->check(($old->snapshot['supplier'] ?? null) === $supplier->only(['id', 'name', 'version']), 'supplier_id', 'O fornecedor mudou desde a preparação. Devolva e actualize a versão.'); + } + if ($kind === 'policy' && $state === 'approved') { + if (($data['forecast_method'] ?? 'manual') === 'historical') { + $history = app(SupplyHistory::class)->reference($data); + $this->check($history['available'], 'action', $history['reason'] ?? 'Histórico não disponível.'); + } + if (! empty($data['agreement_key'])) { + $agreement = $this->latest('agreement')->where('record_key', $data['agreement_key'])->firstOrFail(); + $this->check($agreement->state === 'approved' && $agreement->version === $data['agreement_version'], 'action', 'O acordo mudou desde a preparação. Devolva e seleccione a versão actual.'); + } + $clash = $this->latest('policy')->where('state', 'approved')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->where('record_key', '!=', $old->record_key)->exists(); + $this->check(! $clash, 'action', 'Suspenda a política anterior deste artigo/local antes de aprovar a substituição.'); + } + if ($kind === 'agreement' && $state === 'approved' && $data['habitual']) { + $clash = $this->latest('agreement')->where('state', 'approved')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->get() + ->contains(fn ($r) => $r->record_key !== $old->record_key && $r->data['habitual'] && $r->data['valid_from'] <= $data['valid_until'] && $r->data['valid_until'] >= $data['valid_from']); + $this->check(! $clash, 'action', 'Já existe fornecedor habitual com vigência sobreposta neste artigo/local.'); + } + if ($kind === 'import' && $old === null) { + $this->check(! $this->latest('import')->where('purchase_order_id', $order->id)->exists(), 'purchase_order_id', 'A ordem já tem processo de importação. Consulte-o sem duplicar a compra.'); + $this->check(! GoodsReceipt::query()->where('purchase_order_id', $order->id)->exists(), 'purchase_order_id', 'Registe a importação antes da primeira recepção da ordem.'); + } + $snapshot = $action === 'save' ? ['prepared_by' => $actor->id, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])), 'item' => $item?->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse?->only(['id', 'code', 'name']), + 'supplier' => $supplier?->only(['id', 'name', 'version']), 'order' => $order?->only(['id', 'number', 'currency', 'supplier_id', 'total_minor'])] : $old->snapshot; + if ($state === 'approved') { + $this->check($snapshot['item'] === $item?->only(['id', 'code', 'name', 'unit']), 'action', 'O cadastro do artigo mudou. Devolva e actualize a preparação.'); + } + $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old->record_key ?? (string) Str::uuid(), + 'version' => ($old->version ?? 0) + 1, 'state' => $state, 'stock_item_id' => $item?->id, 'warehouse_id' => $warehouse?->id, + 'supplier_id' => $supplier?->id, 'purchase_order_id' => $order?->id, 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, + 'operation_key' => $envelope['operation_key'], 'input_hash' => $hash, 'reason' => $envelope['reason'], 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'supply.'.$kind.'.'.$action, null, $old?->toArray(), $record->toArray()); + + return $record; + }); + } + + /** @param array $input + * @return array + */ + private function validateData(string $kind, array $input): array + { + $predecessor = null; + if ($kind === 'policy' && ! empty($input['predecessor_key'])) { + $source = Validator::make($input, ['predecessor_key' => 'required|uuid', 'predecessor_version' => 'required|integer|min:1'])->validate(); + $predecessor = SupplyRecord::query()->where('kind', 'policy')->where('record_key', $source['predecessor_key'])->where('version', $source['predecessor_version'])->firstOrFail(); + $this->check(in_array($predecessor->state, ['approved', 'suspended'], true), 'data.predecessor_key', 'Reutilize uma versão de política já aprovada.'); + $input = [...$predecessor->data, ...$input]; + } + $rules = match ($kind) { + 'policy' => ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1', + 'predecessor_key' => 'nullable|required_with:predecessor_version|uuid', 'predecessor_version' => 'nullable|required_with:predecessor_key|integer|min:1', + 'forecast_method' => 'nullable|in:manual,historical', + 'history_key' => 'exclude_unless:forecast_method,historical|required|uuid', + 'history_min_days' => 'exclude_unless:forecast_method,historical|required|integer|min:1|max:3660', + 'history_max_age_days' => 'exclude_unless:forecast_method,historical|required|integer|min:1|max:3660', + 'agreement_key' => 'nullable|uuid', + 'purpose' => 'required|in:resale,internal', 'origin' => 'required|in:local,import', + 'safety_milli' => 'required|integer|min:0|max:1000000000', 'minimum_stock_milli' => 'required|integer|min:0|max:1000000000', + 'target_milli' => 'required|integer|min:0|max:1000000000', 'maximum_milli' => 'required|integer|min:0|max:1000000000', + 'daily_milli' => 'nullable|integer|min:0|max:1000000000', 'lead_days' => 'required|integer|min:0|max:3650', 'approval_days' => 'required|integer|min:0|max:365', + 'horizon_days' => 'required|integer|min:1|max:366', 'minimum_milli' => 'required|integer|min:0|max:1000000000', 'pack_milli' => 'required|integer|min:1|max:1000000000', + 'basis' => 'required|string|min:5|max:2000'], + 'agreement' => ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1', 'supplier_id' => 'required|integer|min:1', + 'habitual' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', + 'origin' => 'required|in:local,import', 'currency' => 'required|regex:/^[A-Z]{3}$/', 'price_minor' => 'nullable|integer|min:0|max:1000000000000', + 'lead_days' => 'nullable|integer|min:0|max:3650', 'reference' => 'required|string|max:200'], + 'import' => ['purchase_order_id' => 'required|integer|min:1', 'purpose' => 'required|in:resale,internal', 'jurisdiction' => 'required|string|max:100', + 'origin_country' => 'required|string|max:100', 'dispatch_country' => 'required|string|max:100', + 'incoterm' => 'nullable|string|max:100', 'regime' => 'required|string|max:200', 'expected_usable_on' => 'nullable|date_format:Y-m-d'], + default => throw new \InvalidArgumentException('Tipo de fornecimento desconhecido.'), + }; + $data = Validator::make($input, $rules)->validate(); + if ($predecessor !== null) { + $this->check((int) $data['stock_item_id'] === $predecessor->stock_item_id && (int) $data['warehouse_id'] === $predecessor->warehouse_id, 'data.stock_item_id', 'A política reutilizada mantém o artigo e local de origem.'); + $data['predecessor_version'] = (int) $data['predecessor_version']; + } + if ($kind === 'policy' && ($data['forecast_method'] ?? 'manual') === 'historical') { + $data['daily_milli'] = null; + $data['history_min_days'] = (int) $data['history_min_days']; + $data['history_max_age_days'] = (int) $data['history_max_age_days']; + } + if ($kind === 'policy') { + $this->check($data['safety_milli'] <= $data['minimum_stock_milli'] && $data['minimum_stock_milli'] <= $data['target_milli'] && $data['target_milli'] <= $data['maximum_milli'], 'data.target_milli', 'Respeite segurança ≤ mínimo ≤ alvo ≤ máximo.'); + } + + return $data; + } + + private function check(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/SupplySuggestions.php b/app/Services/SupplySuggestions.php new file mode 100644 index 0000000..b14350b --- /dev/null +++ b/app/Services/SupplySuggestions.php @@ -0,0 +1,254 @@ + $input */ + public function save(User $actor, array $input): SupplyRecord + { + abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.'); + + return DB::transaction(function () use ($actor, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', + 'action' => 'required|in:save,approve,return,cancel,convert,close', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); + $permission = match ($v['action']) { + 'save', 'cancel' => 'supply_suggest_prepare', + 'approve', 'return', 'close' => 'supply_suggest_review', + 'convert' => 'supply_suggest_convert', + default => throw new \InvalidArgumentException('Acção de sugestão desconhecida.'), + }; + abort_unless(app(SupplyPlanning::class)->allows($actor, $permission), 403); + if ($v['action'] === 'convert') { + abort_unless(app(OrganizationAccess::class)->canRequest($actor), 403); + } + $hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR)); + $existing = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first(); + if ($existing !== null) { + $this->require($existing->kind === 'suggestion' && $existing->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.'); + + return $existing; + } + $old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest('suggestion')->where('record_key', $v['record_key'])->firstOrFail() : null; + $this->require((int) $v['version'] === ($old->version ?? 0), 'version', 'A sugestão mudou. Actualize antes de continuar.'); + if ($v['action'] === 'save') { + $this->require($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Uma sugestão aprovada ou convertida mantém a versão decidida.'); + $data = Validator::make($v['data'], ['policy_key' => 'required|uuid', 'quantity_milli' => 'nullable|integer|min:1|max:1000000000', 'delivery_on' => 'nullable|date_format:Y-m-d|after_or_equal:today', + 'unit_price' => ['nullable', 'numeric', 'min:0.01', 'max:100000000', 'regex:/^\d+(\.\d{1,2})?$/'], 'tax_rate' => ['nullable', 'numeric', 'between:0,100', 'regex:/^\d+(\.\d{1,2})?$/']])->validate(); + $this->require($old === null || $old->data['policy_key'] === $data['policy_key'], 'data.policy_key', 'A revisão conserva a política de origem.'); + $basis = $this->basis($data['policy_key'], today()->toDateString()); + $active = app(SupplyPlanning::class)->latest('suggestion')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id']) + ->where('id', '!=', $old->id ?? 0)->whereIn('state', ['draft', 'returned', 'approved', 'converted'])->exists(); + $this->require(! $active, 'data.policy_key', 'Já existe sugestão ou requisição acompanhada para este artigo/local. Consulte e reveja a existente.'); + $projection = $basis['projection']; + $quantity = isset($data['quantity_milli']) ? (int) $data['quantity_milli'] : ($projection['quantity_milli'] ?? 0); + $delivery = $data['delivery_on'] ?? $projection['risk_date'] ?? null; + $this->require($quantity > 0 && is_string($delivery), 'data.quantity_milli', 'Sem necessidade calculada: indique quantidade e data manuais fundamentadas.'); + $this->require($delivery >= today()->toDateString(), 'data.delivery_on', 'A data pretendida deve ser hoje ou uma data futura.'); + $this->require($delivery <= CarbonImmutable::today()->addDays($basis['policy']['horizon_days'] - 1)->toDateString(), 'data.delivery_on', 'Escolha uma data dentro do horizonte da política para conferir o impacto.'); + $this->require($quantity >= $basis['policy']['minimum_milli'] && $quantity % $basis['policy']['pack_milli'] === 0, 'data.quantity_milli', 'Respeite a quantidade mínima e o múltiplo de embalagem da política.'); + $price = $data['unit_price'] ?? $basis['unit_price']; + $tax = $data['tax_rate'] ?? $basis['tax_rate']; + $this->require($price !== null, 'data.unit_price', 'Indique estimativa em MZN com fonte no fundamento; não existe preço local reutilizável.'); + $this->require($tax !== null, 'data.tax_rate', 'Confirme a taxa aplicável; não existe valor configurado reutilizável.'); + $draftForm = new StoreRequisitionRequest; + $draftForm->setUserResolver(fn () => $actor); + $draftRules = $draftForm->rules(); + Validator::make(['items' => [['quantity' => BigDecimal::of($quantity)->dividedBy(1000, 3)->__toString(), 'unit_price' => $price, 'tax_rate' => $tax]]], + array_intersect_key($draftRules, array_flip(['items.*.quantity', 'items.*.unit_price', 'items.*.tax_rate'])), $draftForm->messages())->validate(); + $snapshot = $basis; + $snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])); + $snapshot['prepared_on'] = today()->toDateString(); + $data = ['policy_key' => $data['policy_key'], 'policy_version' => $basis['policy_version'], 'quantity_milli' => $quantity, 'delivery_on' => $delivery, + 'unit_price' => BigDecimal::of($price)->toScale(2)->__toString(), 'tax_rate' => BigDecimal::of($tax)->toScale(2)->__toString(), + 'price_source' => $basis['unit_price'] !== null && BigDecimal::of($price)->isEqualTo($basis['unit_price']) ? 'agreement' : 'manual_estimate', + 'tax_source' => $basis['tax_rate'] !== null && BigDecimal::of($tax)->isEqualTo($basis['tax_rate']) ? 'company_default' : 'manual_rate', + 'manual_plan' => $quantity !== ($projection['quantity_milli'] ?? 0) || $delivery !== ($projection['risk_date'] ?? null) || ! ($projection['demand_complete'] ?? false)]; + $state = 'draft'; + } else { + $this->require($old !== null, 'action', 'Prepare primeiro a sugestão.'); + $data = $old->data; + $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.'); + } + if ($v['action'] === 'approve') { + $this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão não está disponível para decisão.'); + $this->current($old); + $this->require(! $this->hasExcess($old), 'action', 'A reposição excede o máximo no horizonte. Reveja quantidade, calendário ou política antes da aprovação.'); + if ($data['manual_plan'] || $snapshot['events']['warnings'] !== []) { + $ack = Validator::make($v['data'], ['confirm_exception' => 'required|accepted'])->validate(); + $data['exception_confirmed'] = (bool) $ack['confirm_exception']; + } + $state = 'approved'; + } elseif ($v['action'] === 'return') { + $this->require(in_array($state, ['draft', 'approved'], true), 'action', 'A sugestão já não pode ser devolvida nesta etapa.'); + $state = 'returned'; + } elseif ($v['action'] === 'cancel') { + $this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão aprovada deve ser devolvida; a convertida conserva a requisição.'); + $state = 'cancelled'; + } elseif ($v['action'] === 'convert') { + $this->require($state === 'approved', 'action', 'Só uma sugestão aprovada e ainda não convertida pode criar requisição.'); + $this->require(($snapshot['reviewed_by'] ?? null) !== $actor->id, 'action', 'A pessoa que conferiu não pode tornar-se autora da requisição.'); + $this->current($old); + $conversion = Validator::make($v['data'], ['cost_center_id' => 'required|integer|min:1'])->validate(); + $catalogue = $snapshot['catalogue']; + $payload = ['cost_center_id' => $conversion['cost_center_id'], 'priority' => $data['delivery_on'] <= today()->toDateString() ? 'urgent' : 'normal', + 'needed_by' => $data['delivery_on'], 'justification' => $v['reason'], 'items' => [['catalogue_entry_id' => $catalogue['id'], 'catalogue_version' => $catalogue['version'], + 'quantity' => BigDecimal::of($data['quantity_milli'])->dividedBy(1000, 3)->__toString(), 'unit_price' => $data['unit_price'], 'tax_rate' => $data['tax_rate']]]]; + $form = new StoreRequisitionRequest; + $form->setUserResolver(fn () => $actor); + $payload = Validator::make($payload, $form->rules(), $form->messages())->validate(); + $requisition = app(RequisitionWorkflow::class)->save($actor, $payload); + $data['requisition_id'] = $requisition->id; + $data['requisition_number'] = $requisition->number; + $snapshot['conversion'] = ['actor_id' => $actor->id, 'approval_version' => $old->version, 'cost_center_id' => $requisition->cost_center_id, + 'requisition_created' => [...$requisition->only(['id', 'number', 'cost_center_id', 'subtotal_minor', 'tax_minor', 'total_minor']), + 'items' => $requisition->items()->get()->map(fn ($line) => $line->only(['id', 'description', 'unit', 'quantity', 'unit_price_minor', 'tax_rate', 'catalogue_entry_id', 'catalogue_snapshot', 'total_minor']))->all()]]; + $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.'); + $state = 'closed'; + } + if ($v['action'] === 'approve') { + $snapshot['reviewed_by'] = $actor->id; + } + } + $snapshot['actor'] = $actor->only(['id', 'name']); + $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'suggestion', 'record_key' => $old->record_key ?? (string) Str::uuid(), + 'version' => ($old->version ?? 0) + 1, 'state' => $state, 'stock_item_id' => $snapshot['stock_item_id'], 'warehouse_id' => $snapshot['warehouse_id'], + 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $v['reason'], 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'supply.suggestion.'.$v['action'], null, $old?->toArray(), $record->toArray()); + + return $record; + }); + } + + /** A repeatable source snapshot; never writes business events. + * @return array + */ + public function basis(string $policyKey, string $date): array + { + $planning = app(SupplyPlanning::class); + $policy = $planning->latest('policy')->where('record_key', $policyKey)->firstOrFail(); + $this->require($policy->state === 'approved', 'data.policy_key', 'Seleccione uma política aprovada e vigente.'); + $stock = StockItem::query()->whereKey($policy->stock_item_id)->firstOrFail(); + $warehouse = Warehouse::query()->whereKey($policy->warehouse_id)->firstOrFail(); + $this->require($stock->only(['id', 'code', 'name', 'unit']) === $policy->snapshot['item'], 'data.policy_key', 'O artigo mudou; reveja a política antes de preparar uma sugestão.'); + $this->require(Schema::hasTable('requisition_catalogue'), 'data.policy_key', 'Instale e publique o catálogo antes de preparar sugestões convertíveis.'); + $catalogue = DB::table('requisition_catalogue')->where('tenant_id', $policy->tenant_id)->where('stock_item_id', $stock->id)->first(); + $this->require($catalogue !== null && $catalogue->published && json_decode($catalogue->source_snapshot, true, 16, JSON_THROW_ON_ERROR) === app(RequisitionCatalogue::class)->source($stock), 'data.policy_key', 'Publique a versão actual do artigo no catálogo para reutilizar os seus dados na requisição.'); + $agreement = null; + $price = null; + $supplierBasis = null; + if (! empty($policy->data['agreement_key'])) { + $agreement = $planning->latest('agreement')->where('record_key', $policy->data['agreement_key'])->firstOrFail(); + $supplier = Supplier::query()->whereKey($agreement->supplier_id)->firstOrFail(); + $this->require($agreement->state === 'approved' && $agreement->version === $policy->data['agreement_version'] && $agreement->data['valid_from'] <= today()->toDateString() && $agreement->data['valid_until'] >= today()->toDateString() && $supplier->eligible(), 'data.policy_key', 'O acordo de origem ou a validação do fornecedor deixou de estar vigente. Reveja a política.'); + $this->require($agreement->snapshot['supplier'] === $supplier->only(['id', 'name', 'version']), 'data.policy_key', 'A versão do fornecedor mudou desde o acordo. Reveja o acordo e a política.'); + if ($agreement->data['currency'] === 'MZN' && $agreement->data['price_minor'] !== null && $agreement->data['price_minor'] > 0) { + $price = BigDecimal::of($agreement->data['price_minor'])->dividedBy(100, 2)->__toString(); + } + $supplierBasis = $supplier->only(['id', 'version', 'status', 'blocked', 'next_review']); + } + $balance = app(StockWorkflow::class)->balance($stock->id, $warehouse->id); + $events = app(SupplyCoordination::class)->events($stock->id, $warehouse->id, $date); + $forecast = app(SupplyHistory::class)->resolve($policy); + $projection = app(InventoryLots::class)->project($policy, $forecast['data'], $balance, $events, $date); + $sources = []; + foreach (['reservation', 'delivery', 'import'] as $kind) { + $rows = $planning->latest($kind); + if ($kind !== 'import') { + $rows->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id); + } else { + $orderIds = $planning->latest('delivery')->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id)->pluck('purchase_order_id'); + $rows->whereIn('purchase_order_id', $orderIds); + } + $sources[$kind] = $rows->orderBy('id')->get(['id', 'record_key', 'version', 'state'])->toArray(); + } + $tax = Tenant::query()->findOrFail($policy->tenant_id)->default_tax_rate; + $basis = ['stock_item_id' => $stock->id, 'warehouse_id' => $warehouse->id, 'item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), + 'policy_key' => $policy->record_key, 'policy_version' => $policy->version, 'policy' => $policy->data, + 'catalogue' => ['id' => $catalogue->id, 'version' => $catalogue->version, 'category' => $catalogue->category, 'purchase_type' => $catalogue->purchase_type], + 'agreement' => $agreement?->only(['id', 'record_key', 'version', 'data', 'snapshot']), 'supplier' => $supplierBasis, + 'balance' => $balance, 'events' => $events, 'current_event_status' => app(SupplyCoordination::class)->events($stock->id, $warehouse->id, today()->toDateString()), + 'projection' => $projection, 'sources' => $sources, 'unit_price' => $price, 'tax_rate' => $tax]; + if ($forecast['history'] !== null) { + $basis['forecast_history'] = $forecast['history']; + } + $basis['hash'] = hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR)); + + return $basis; + } + + public function hasExcess(SupplyRecord $record): bool + { + $max = $record->snapshot['policy']['maximum_milli']; + if (! ($record->snapshot['projection']['available'] ?? false)) { + return $record->snapshot['balance']['quantity'] + $record->data['quantity_milli'] > $max; + } + foreach ($record->snapshot['projection']['series'] ?? [] as $point) { + if ($point['date'] >= $record->data['delivery_on'] && $max < $point['balance_milli'] + $record->data['quantity_milli']) { + return true; + } + } + + return false; + } + + /** Minimal provenance for a requisition already authorised by its controller. + * @return array|null + */ + public function origin(Requisition $requisition): ?array + { + if (! Schema::hasTable('supply_records')) { + return null; + } + $record = app(SupplyPlanning::class)->latest('suggestion')->whereIn('state', ['converted', 'closed'])->whereJsonContains('data->requisition_id', $requisition->id)->first(); + if ($record === null) { + return null; + } + + return ['approval_version' => $record->snapshot['conversion']['approval_version'], 'prepared_on' => $record->snapshot['prepared_on'], + 'quantity_milli' => $record->data['quantity_milli'], 'unit' => $record->snapshot['item']['unit'], 'delivery_on' => $record->data['delivery_on'], + 'purpose' => $record->snapshot['policy']['purpose'], 'origin' => $record->snapshot['policy']['origin']]; + } + + private function current(SupplyRecord $record): void + { + $this->require($record->data['delivery_on'] >= today()->toDateString(), 'action', 'A data pretendida passou. Devolva e actualize a sugestão.'); + $basis = $this->basis($record->data['policy_key'], $record->snapshot['prepared_on']); + $this->require($basis['hash'] === $record->snapshot['hash'], 'action', 'O stock, as fontes ou a configuração mudaram. Devolva e actualize a sugestão antes de decidir ou converter.'); + } + + private function require(bool $condition, string $field, string $message): void + { + if (! $condition) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/WarehouseAvailability.php b/app/Services/WarehouseAvailability.php new file mode 100644 index 0000000..d83eefd --- /dev/null +++ b/app/Services/WarehouseAvailability.php @@ -0,0 +1,42 @@ + */ + public function row(StockItem $item, Warehouse $warehouse, bool $planning): array + { + abort_unless($item->tenant_id === $warehouse->tenant_id && auth()->user()?->tenant_id === $item->tenant_id, 404); + $physical = app(StockWorkflow::class)->balance($item->id, $warehouse->id); + $lots = app(InventoryLots::class)->context($item->id, $warehouse->id); + $usable = $lots === null ? $physical['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null); + $needs = null; + $minimum = null; + if ($planning && Schema::hasTable('supply_records')) { + $needs = 0; + foreach (app(SupplyPlanning::class)->latest('reservation')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->whereIn('state', ['active', 'fulfilled'])->get() as $need) { + if (($need->snapshot['item']['unit'] ?? null) !== $item->unit) { + $needs = null; + break; + } + $needs += app(SupplyCoordination::class)->remainingReservation($need); + } + $policy = app(SupplyPlanning::class)->latest('policy')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->where('state', 'approved')->first(); + if ($policy !== null && $policy->snapshot['item'] === $item->only(['id', 'code', 'name', 'unit'])) { + $minimum = $policy->data['minimum_stock_milli'] ?? null; + } + } + + return ['id' => $item->id, 'code' => $item->code, 'name' => $item->name, 'unit' => $item->unit, + 'physical_milli' => $physical['quantity'], 'usable_milli' => $usable, 'blocked_milli' => $lots === null || ! $lots['reconciled'] ? null : $lots['blocked_milli'], + 'lot_control' => $lots !== null, 'reconciled' => $lots === null || $lots['reconciled'], 'planned_milli' => $needs, + 'minimum_milli' => $minimum, 'at_minimum' => $usable !== null && $minimum !== null && $usable - app(WarehouseReservations::class)->committed($item->id, $warehouse->id) <= $minimum, + 'physical_commitment_milli' => app(WarehouseReservations::class)->ready() ? app(WarehouseReservations::class)->committed($item->id, $warehouse->id) : null, + 'free_milli' => $usable === null ? null : $usable - app(WarehouseReservations::class)->committed($item->id, $warehouse->id), 'ledger_version' => $physical['version']]; + } +} diff --git a/app/Services/WarehouseFulfilment.php b/app/Services/WarehouseFulfilment.php new file mode 100644 index 0000000..a1bbb5d --- /dev/null +++ b/app/Services/WarehouseFulfilment.php @@ -0,0 +1,202 @@ + */ + public function progress(WarehouseRequest $request): array + { + $events = Schema::hasTable('warehouse_request_events') ? WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->orderBy('version')->get() : collect(); + $state = 'draft'; + $reviewer = null; + $partial = false; + $partialRequested = false; + $delivered = array_fill(0, count($request->snapshot['lines']), 0); + $receipts = []; + foreach ($events as $event) { + if ($event->action === 'submit') { + $state = 'submitted'; + $partialRequested = $event->data['partial_allowed'] ?? false; + } + if ($event->action === 'reject') { + $state = 'rejected'; + } + if ($event->action === 'cancel') { + $state = 'cancelled'; + } + if ($event->action === 'authorize') { + $state = 'authorized'; + $reviewer = $event->actor_id; + $partial = $event->data['partial_allowed']; + } + if ($event->action === 'deliver') { + foreach ($event->data['lines'] as $line) { + $delivered[$line['index']] += $line['quantity_milli']; + } $state = 'partial'; + } + if ($event->action === 'receive') { + $receipts[] = $event->data['delivery_event_id']; + } + } + $remaining = []; + foreach ($request->snapshot['lines'] as $index => $line) { + $remaining[$index] = $line['quantity_milli'] - $delivered[$index]; + } + $cancelled = $state === 'cancelled' ? $remaining : array_fill(0, count($remaining), 0); + if ($state === 'cancelled') { + $remaining = array_fill(0, count($remaining), 0); + } + if ($state === 'partial' && array_sum($remaining) === 0) { + $state = 'delivered'; + } + if ($state === 'delivered' && count($receipts) === $events->where('action', 'deliver')->count()) { + $state = 'closed'; + } + + return ['state' => $state, 'version' => $events->max('version') ?? 0, 'reviewer_id' => $reviewer, 'partial_allowed' => $partial, 'partial_requested' => $partialRequested, + 'delivered' => $delivered, 'remaining' => $remaining, 'cancelled' => $cancelled, 'reserved' => app(WarehouseReservations::class)->lines($request), 'reservation_ready' => app(WarehouseReservations::class)->ready(), 'received_deliveries' => $receipts, 'events' => $events->map(fn ($event) => $event->only(['id', 'version', 'action', 'actor_id', 'data', 'reason', 'created_at']))->all()]; + } + + /** @param array $input */ + public function command(User $actor, int $id, array $input): WarehouseRequestEvent + { + return DB::transaction(function () use ($actor, $id, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); + abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Instalação da autorização/entrega pendente.'); + $r = WarehouseRequest::query()->lockForUpdate()->findOrFail($id); + $data = Validator::make($input, ['operation_key' => 'required|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:submit,authorize,reject,deliver,receive,reserve,cancel', 'reason' => 'required|string|min:10|max:4000', + 'partial_allowed' => 'sometimes|boolean', 'lines' => 'sometimes|array|min:1|max:50', 'lines.*.index' => 'required|integer|distinct|min:0', 'lines.*.quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', 'delivery_event_id' => 'sometimes|integer|min:1'])->validate(); + $action = $data['action']; + $owner = $r->created_by === $actor->id; + if (in_array($action, ['submit', 'receive'], true)) { + abort_unless($owner && $actor->hasFunctions(['warehouse_request_prepare']), 403); + } + if (in_array($action, ['authorize', 'reject', 'reserve', 'cancel'], true)) { + abort_unless(! $owner && $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id === $r->snapshot['requester']['department_id'], 403); + } + if ($action === 'deliver') { + abort_unless(! $owner && $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']), 403); + } + $hash = hash('sha256', json_encode([$id, $data], JSON_THROW_ON_ERROR)); + $old = WarehouseRequestEvent::query()->where('actor_id', $actor->id)->where('operation_key', $data['operation_key'])->first(); + if ($old !== null) { + $this->check($old->input_hash === $hash, 'operation_key', 'Chave utilizada com outro conteúdo.'); + + return $old; + } + $p = $this->progress($r); + $this->check((int) $data['version'] === $p['version'], 'version', 'O pedido mudou. Actualize antes de continuar.'); + $eventData = []; + if (in_array($action, ['reserve', 'cancel'], true)) { + $this->check(in_array($p['state'], ['authorized', 'partial'], true), 'action', 'A reserva/libertação exige um pedido autorizado com saldo pendente.'); + } + if ($action === 'submit') { + $this->check($p['state'] === 'draft', 'action', 'Só pode submeter um rascunho.'); + $eventData = ['partial_allowed' => $data['partial_allowed'] ?? false]; + } + if (in_array($action, ['authorize', 'reject'], true)) { + $this->check($p['state'] === 'submitted', 'action', 'O pedido não aguarda decisão.'); + } + if ($action === 'authorize') { + $partial = $data['partial_allowed'] ?? false; + $this->check(! $partial || $p['partial_requested'], 'partial_allowed', 'O requisitante não aceitou entregas parciais na submissão.'); + $eventData = ['partial_allowed' => $partial, 'availability' => []]; + $grouped = []; + foreach ($r->snapshot['lines'] as $line) { + $grouped[$line['item']['id']] = ($grouped[$line['item']['id']] ?? 0) + $line['quantity_milli']; + } + foreach ($grouped as $itemId => $quantity) { + $balance = app(StockWorkflow::class)->balance($itemId, $r->warehouse_id); + $lots = app(InventoryLots::class)->context($itemId, $r->warehouse_id); + $usable = $lots === null ? $balance['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null); + $this->check($usable !== null && ($partial || $usable >= $quantity), 'availability', 'Stock utilizável insuficiente para autorização de entrega integral.'); + $eventData['availability'][] = ['stock_item_id' => $itemId, 'usable_milli' => $usable, 'requested_milli' => $quantity, 'ledger_version' => $balance['version']]; + } + } + if (in_array($action, ['submit', 'authorize', 'deliver', 'reserve'], true)) { + foreach ($r->snapshot['lines'] as $line) { + $item = StockItem::query()->whereKey($line['item']['id'])->firstOrFail(); + $this->check($item->only(['id', 'code', 'name', 'unit']) === $line['item'], 'source', 'O cadastro do artigo mudou; é necessário um novo rascunho conferido.'); + if ($line['equipment'] !== null) { + $equipment = Equipment::query()->whereKey($line['equipment']['id'])->firstOrFail(); + $this->check($equipment->only(['id', 'code', 'description']) === $line['equipment'] && $equipment->department_id === $r->snapshot['requester']['department_id'], 'source', 'O equipamento de destino mudou. Reveja a origem.'); + } + } + } + if ($action === 'deliver') { + $this->check(in_array($p['state'], ['authorized', 'partial'], true), 'action', 'A entrega exige uma requisição autorizada com saldo pendente.'); + abort_if($actor->id === $p['reviewer_id'], 403, 'Quem autorizou não pode entregar.'); + $this->check(! empty($data['lines']), 'lines', 'Indique as quantidades efectivamente entregues.'); + $quantities = array_fill(0, count($p['remaining']), 0); + foreach ($data['lines'] as $line) { + $index = (int) $line['index']; + $quantity = ReceiptWorkflow::milli($line['quantity']); + $this->check(isset($p['remaining'][$index]) && $quantity > 0 && $quantity <= $p['remaining'][$index], 'lines', 'Quantidade fora do saldo autorizado.'); + $quantities[$index] = $quantity; + } + if (! $p['partial_allowed']) { + $this->check($quantities === $p['remaining'], 'lines', 'A decisão exige entrega integral.'); + } + $eventData['lines'] = []; + foreach ($quantities as $index => $quantity) { + if ($quantity === 0) { + continue; + } + $line = $r->snapshot['lines'][$index]; + app(WarehouseReservations::class)->consume($actor, $r, $index, $quantity, $data['operation_key']); + $operation = app(StockWorkflow::class)->record($actor, ['kind' => 'issue', 'reference' => 'RA-'.$r->id.'-V'.($p['version'] + 1).'-L'.$index, + 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $r->created_by, 'warehouse_id' => $r->warehouse_id, 'stock_item_id' => $line['item']['id'], + 'quantity' => intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT), 'destination' => $r->snapshot['destination'], 'reason' => 'Saída pela requisição RA-'.$r->id.': '.$data['reason'], + ...($line['equipment'] === null ? [] : ['equipment_id' => $line['equipment']['id']])]); + $eventData['lines'][] = ['index' => $index, 'quantity_milli' => $quantity, 'stock_operation_id' => $operation->id]; + } + $eventData['recipient'] = $r->snapshot['requester']; + } + if ($action === 'receive') { + $delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'deliver')->whereKey($data['delivery_event_id'] ?? 0)->firstOrFail(); + $this->check(! in_array($delivery->id, $p['received_deliveries'], true), 'delivery_event_id', 'Recepção já confirmada.'); + $eventData = ['delivery_event_id' => $delivery->id]; + } + $eventData['actor'] = $actor->only(['id', 'name']); + $event = WarehouseRequestEvent::query()->create(['tenant_id' => $actor->tenant_id, 'warehouse_request_id' => $id, 'actor_id' => $actor->id, 'version' => $p['version'] + 1, + 'operation_key' => $data['operation_key'], 'input_hash' => $hash, 'action' => $action, 'reason' => $data['reason'], 'data' => $eventData, 'created_at' => now()]); + if ($action === 'authorize') { + app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $eventData['partial_allowed'], $data['operation_key']); + } + if ($action === 'reserve') { + $authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'authorize')->firstOrFail(); + app(WarehouseReservations::class)->reserve($actor, $r, $authorization, $p['remaining'], $p['partial_allowed'], $data['operation_key']); + } + if ($action === 'cancel') { + app(WarehouseReservations::class)->release($actor, $r, $data['operation_key']); + } + app(RequisitionWorkflow::class)->audit($actor, 'warehouse_request.'.$action, null, null, ['warehouse_request_id' => $id, 'event_id' => $event->id, 'data' => $eventData, 'reason' => $data['reason']]); + foreach ($r->snapshot['lines'] as $line) { + app(AutomaticPurchases::class)->signal($actor, $line['item']['id'], $r->warehouse_id); + } + + return $event; + }); + } + + private function check(bool $valid, string $field, string $message): void + { + if (! $valid) { + throw ValidationException::withMessages([$field => $message]); + } + } +} diff --git a/app/Services/WarehouseRequests.php b/app/Services/WarehouseRequests.php new file mode 100644 index 0000000..7460104 --- /dev/null +++ b/app/Services/WarehouseRequests.php @@ -0,0 +1,74 @@ + $input */ + public function draft(User $actor, array $input): WarehouseRequest + { + return DB::transaction(function () use ($actor, $input) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + abort_unless($actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403); + abort_unless(Schema::hasTable('warehouse_requests'), 503, 'Instalação de requisições ao armazém pendente.'); + $data = Validator::make($input, ['operation_key' => 'required|uuid', 'warehouse_id' => 'required|integer|min:1', 'cost_center_id' => 'required|integer|min:1', + 'destination' => 'required|string|min:3|max:300', 'reason' => 'required|string|min:10|max:4000', 'priority' => 'required|in:normal,urgent', + 'lines' => 'required|array|min:1|max:50', 'lines.*.stock_item_id' => 'required|integer|min:1', 'lines.*.quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', + 'lines.*.use' => 'required|in:internal,equipment', 'lines.*.equipment_id' => 'nullable|integer|min:1'])->validate(); + $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + $old = WarehouseRequest::query()->where('created_by', $actor->id)->where('operation_key', $data['operation_key'])->first(); + if ($old !== null) { + if ($old->input_hash !== $hash) { + throw ValidationException::withMessages(['operation_key' => 'Chave utilizada com outro conteúdo.']); + } + + return $old; + } + $warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail(); + $center = CostCenter::query()->where('department_id', $actor->department_id)->whereKey($data['cost_center_id'])->firstOrFail(); + $lines = []; + foreach ($data['lines'] as $index => $line) { + $item = StockItem::query()->whereKey($line['stock_item_id'])->firstOrFail(); + $quantity = ReceiptWorkflow::milli($line['quantity']); + if ($quantity <= 0) { + throw ValidationException::withMessages(['lines.'.$index.'.quantity' => 'A quantidade deve ser positiva.']); + } + $equipment = null; + if ($line['use'] === 'equipment') { + if (empty($line['equipment_id'])) { + throw ValidationException::withMessages(['lines.'.$index.'.equipment_id' => 'Seleccione o equipamento de destino.']); + } + $equipment = Equipment::query()->where('department_id', $actor->department_id)->whereKey($line['equipment_id'])->firstOrFail(); + $part = SparePart::query()->where('stock_item_id', $item->id)->first(); + if ($part !== null) { + app(EquipmentWorkflow::class)->linkedPart($part->id, $equipment->id); + } + } elseif (! empty($line['equipment_id'])) { + throw ValidationException::withMessages(['lines.'.$index.'.equipment_id' => 'Escolha utilização em equipamento para associar este destino.']); + } + $lines[] = ['item' => $item->only(['id', 'code', 'name', 'unit']), 'quantity_milli' => $quantity, 'use' => $line['use'], + 'destination' => $data['destination'], 'equipment' => $equipment?->only(['id', 'code', 'description'])]; + } + $record = WarehouseRequest::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'warehouse_id' => $warehouse->id, + 'cost_center_id' => $center->id, 'operation_key' => $data['operation_key'], 'input_hash' => $hash, 'status' => 'draft', 'created_at' => now(), + 'snapshot' => ['requester' => $actor->only(['id', 'name', 'department_id']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), + 'cost_center' => $center->only(['id', 'code', 'name']), 'destination' => $data['destination'], 'reason' => $data['reason'], 'priority' => $data['priority'], 'lines' => $lines]]); + app(RequisitionWorkflow::class)->audit($actor, 'warehouse_request.drafted', null, null, ['warehouse_request_id' => $record->id, 'snapshot' => $record->snapshot]); + + return $record; + }); + } +} diff --git a/app/Services/WarehouseReservations.php b/app/Services/WarehouseReservations.php new file mode 100644 index 0000000..263e8d1 --- /dev/null +++ b/app/Services/WarehouseReservations.php @@ -0,0 +1,124 @@ +ready() ? (int) DB::table('warehouse_reservations')->where('tenant_id', auth()->user()?->tenant_id)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->sum('quantity_milli') : 0; + } + + /** @return array */ + public function lines(WarehouseRequest $request): array + { + $values = array_fill(0, count($request->snapshot['lines']), 0); + if ($this->ready()) { + foreach (DB::table('warehouse_reservations')->where('tenant_id', $request->tenant_id)->where('warehouse_request_id', $request->id)->selectRaw('line_index,SUM(quantity_milli) as quantity')->groupBy('line_index')->get() as $row) { + $values[$row->line_index] = (int) $row->quantity; + } + } + + return $values; + } + + public function usable(int $item, int $warehouse): int + { + $lots = app(InventoryLots::class)->context($item, $warehouse); + if ($lots !== null && ! $lots['reconciled']) { + throw ValidationException::withMessages(['availability' => 'Reconcilie os lotes antes de comprometer ou retirar stock.']); + } + + return $lots === null ? app(StockWorkflow::class)->balance($item, $warehouse)['quantity'] : $lots['usable_milli']; + } + + public function free(int $item, int $warehouse): int + { + return $this->usable($item, $warehouse) - $this->committed($item, $warehouse); + } + + /** @param array $remaining */ + public function reserve(User $actor, WarehouseRequest $request, WarehouseRequestEvent $authorization, array $remaining, bool $partial, string $key): void + { + if (! $this->ready()) { + return; + } + $own = $this->lines($request); + foreach ($request->snapshot['lines'] as $index => $line) { + $need = $remaining[$index] - $own[$index]; + if ($need <= 0) { + continue; + } + $free = max(0, $this->free($line['item']['id'], $request->warehouse_id)); + if (! $partial && $free < $need) { + throw ValidationException::withMessages(['availability' => 'Stock livre insuficiente: parte do material está reservada para outros pedidos.']); + } + $quantity = min($need, $free); + if ($quantity > 0) { + $this->entry($actor, $request, $index, $authorization->id, $key, 'reserve', $quantity); + } + } + } + + public function consume(User $actor, WarehouseRequest $request, int $index, int $quantity, string $key): void + { + if (! $this->ready()) { + return; + } + $own = $this->lines($request); + if ($quantity > $own[$index]) { + throw ValidationException::withMessages(['lines' => 'A entrega excede a reserva desta linha. Actualize a reserva do pedido.']); + } + $authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'authorize')->firstOrFail(); + $this->entry($actor, $request, $index, $authorization->id, $key, 'consume', -$quantity); + } + + public function release(User $actor, WarehouseRequest $request, string $key): void + { + if (! $this->ready()) { + return; + } + $authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'authorize')->first(); + if ($authorization === null) { + return; + } + foreach ($this->lines($request) as $index => $quantity) { + if ($quantity > 0) { + $this->entry($actor, $request, $index, $authorization->id, $key, 'release', -$quantity); + } + } + } + + private function entry(User $actor, WarehouseRequest $request, int $index, int $authorization, string $key, string $kind, int $quantity): void + { + if (DB::transactionLevel() === 0) { + throw new \LogicException('Reserva exige a transacção do processo.'); + } + DB::table('warehouse_reservations')->insert(['tenant_id' => $request->tenant_id, 'warehouse_request_id' => $request->id, 'line_index' => $index, + 'stock_item_id' => $request->snapshot['lines'][$index]['item']['id'], 'warehouse_id' => $request->warehouse_id, 'authorization_event_id' => $authorization, + 'actor_id' => $actor->id, 'command_key' => $key, 'kind' => $kind, 'quantity_milli' => $quantity, 'created_at' => now()]); + } + + /** Refuse worsening a shortage of usable reserved material; quality holds remain independent. */ + public function protect(int $item, int $warehouse, int $before): void + { + if (! $this->ready() || $this->committed($item,$warehouse) === 0) { + return; + } + if ($this->free($item,$warehouse) < min(0,$before)) { + throw ValidationException::withMessages(['quantity' => 'Esta operação consumiria material reservado. Liberte ou regularize a reserva autorizadamente.']); + } + } +} diff --git a/app/Services/WorkContext.php b/app/Services/WorkContext.php new file mode 100644 index 0000000..bed81f1 --- /dev/null +++ b/app/Services/WorkContext.php @@ -0,0 +1,156 @@ + ['area' => 'procurement', 'areaLabel' => 'Compras', 'label' => 'Compras internas', 'operational' => true], + 'procurement.sales' => ['area' => 'procurement', 'areaLabel' => 'Compras', 'label' => 'Venda para terceiros', 'operational' => false], + 'people.internal' => ['area' => 'people', 'areaLabel' => 'Pessoas e contratações', 'label' => 'Contratações internas', 'operational' => true], + 'people.third_party' => ['area' => 'people', 'areaLabel' => 'Pessoas e contratações', 'label' => 'Contratações para terceiros', 'operational' => false], + ]; + + /** @return list */ + public function purchased(User $user): array + { + if (! in_array(config('work_context.composition'), ['sgc', 'sgf'], true)) { + return []; + } + $keys = config('work_context.entitlements.'.$user->tenant_id, $this->evaluation($user) ? array_keys(self::OPTIONS) : []); + if (config('work_context.composition') === 'sgf' && is_array($keys)) { + $keys = array_diff($keys, ['people.third_party']); + } + + return is_array($keys) ? array_values(array_intersect(array_keys(self::OPTIONS), $keys)) : []; + } + + /** @return array|null */ + public function policy(User $user): ?array + { + if ($user->tenant_id === null || ! Schema::hasColumn('tenants', 'work_context_policy')) { + return null; + } + + $value = DB::table('tenants')->where('id', $user->tenant_id)->value('work_context_policy'); + + return $value === null ? ($this->evaluation($user) ? ['revision' => 0, 'enabled' => array_keys(self::OPTIONS), 'evaluation_only' => true] : null) : json_decode($value, true, 32, JSON_THROW_ON_ERROR); + } + + private function evaluation(User $user): bool + { + if (! app()->bound('request') || ! EvaluationNavigation::local(request()) || request()->getPort() !== 8005 + || DB::connection()->getDriverName() !== 'pgsql') { + return false; + } + + return app(EvaluationAccess::class)->source(request())->tenant_id === $user->tenant_id; + } + + public function enforced(User $user): bool + { + // Existing installations require explicit, audited activation. + // New company delivery initialises an empty policy, never implicit grants. + return $this->policy($user) !== null; + } + + /** @return list */ + public function available(User $user): array + { + $enabled = $this->policy($user)['enabled'] ?? []; + $granted = []; + if (app(OrganizationAccess::class)->active($user->tenant_id)) { + $profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id') + ->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id) + ->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.work_contexts', 'p.permissions']); + foreach ($profiles as $profile) { + $permissions = json_decode($profile->permissions, true, 32, JSON_THROW_ON_ERROR); + foreach (json_decode($profile->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR) as $key) { + if ($this->functional($permissions, $key)) { + $granted[] = $key; + } + } + } + } elseif (app()->environment(['testing', 'payable-testing'])) { + // Development roles remain usable locally; never become customer grants. + foreach (array_keys(self::OPTIONS) as $key) { + if ($this->functional(app(OrganizationAccess::class)->permissions($user), $key)) { + $granted[] = $key; + } + } + } + + return array_values(array_intersect($this->purchased($user), $enabled, array_unique($granted))); + } + + /** @param list $permissions */ + private function functional(array $permissions, string $key): bool + { + if (! isset(self::OPTIONS[$key])) { + return false; + } + $people = array_values(array_filter(array_keys(OrganizationAccess::PERMISSIONS), fn ($p) => str_starts_with($p, 'people_'))); + $wanted = self::OPTIONS[$key]['area'] === 'people' ? ['admin', ...$people] : array_diff(array_keys(OrganizationAccess::PERMISSIONS), $people); + + return array_intersect($permissions, $wanted) !== []; + } + + public function active(Request $request): ?string + { + $user = $request->user(); + $state = $request->session()->get('work_context'); + if (! $user instanceof User || ! is_array($state) || ($state['user_id'] ?? null) !== $user->id || ($state['tenant_id'] ?? null) !== $user->tenant_id) { + return null; + } + $key = $state['key'] ?? null; + + return is_string($key) && in_array($key, $this->available($user), true) ? $key : null; + } + + /** @return array */ + public function shared(Request $request): array + { + $user = $request->user(); + $enabled = $user instanceof User && $this->enforced($user); + + $active = $enabled ? $this->active($request) : null; + $permissions = $active !== null ? app(OrganizationAccess::class)->permissions($user, $active) : []; + if ($active === 'procurement.internal' && ! app(OrganizationAccess::class)->active($user->tenant_id) && app(OrganizationAccess::class)->canRequest($user)) { + $permissions[] = 'requester'; + } + $paths = []; + $sharedPaths = []; + foreach (Route::getRoutes()->getRoutes() as $route) { + $path = '/'.$route->uri(); + if (! str_contains($path, '{') && in_array('GET', $route->methods(), true)) { + $area = self::area($path); + if ($area === null) { + $sharedPaths[] = $path; + } + if ($area === null || ($active !== null && self::OPTIONS[$active]['operational'] && self::OPTIONS[$active]['area'] === $area)) { + $paths[] = $path; + } + } + } + + return ['enforced' => $enabled, 'active' => $active, 'paths' => $paths, 'sharedPaths' => $sharedPaths, 'permissions' => $permissions, + 'options' => self::OPTIONS, 'available' => $enabled ? $this->available($user) : [], + 'purchased' => $user instanceof User ? $this->purchased($user) : []]; + } + + public static function area(string $path): ?string + { + $path = '/'.ltrim(explode('?', $path)[0], '/'); + if ($path === '/dashboard' || str_starts_with($path, '/administracao/') || str_starts_with($path, '/empresa') || str_starts_with($path, '/settings') || str_starts_with($path, '/contexto')) { + return null; + } + + return $path === '/pessoas' || str_starts_with($path, '/pessoas/') ? 'people' : 'procurement'; + } +} diff --git a/app/Services/WorkflowNotices.php b/app/Services/WorkflowNotices.php new file mode 100644 index 0000000..e36521b --- /dev/null +++ b/app/Services/WorkflowNotices.php @@ -0,0 +1,121 @@ + $input */ + public function refresh(User $actor, int $id, array $input): void + { + $data = Validator::make($input, ['operation_key' => 'required|uuid', 'process_version' => 'required|integer|min:1', + 'step_id' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:4000'])->validate(); + DB::transaction(function () use ($actor, $id, $data) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + abort_unless($actor->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403); + abort_unless(Schema::hasTable('workflow_notices'), 503, 'Instalação dos avisos pendente.'); + $request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id); + $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + $old = AuditLog::query()->where('user_id', $actor->id)->where('requisition_id', $id)->where('action', 'workflow.routing_refreshed') + ->where('after->operation_key', $data['operation_key'])->first(); + if ($old !== null) { + if (($old->after['input_hash'] ?? null) !== $hash) { + throw ValidationException::withMessages(['operation_key' => 'Chave utilizada com outro conteúdo.']); + } + + return; + } + $step = $request->steps->firstWhere('decision', 'pending'); + if ($request->status !== 'pending' || $step === null || $step->id !== (int) $data['step_id'] + || (int) $request->getAttribute('process_version') !== (int) $data['process_version']) { + throw ValidationException::withMessages(['routing' => 'A etapa ou o pedido mudou. Actualize antes de encaminhar.']); + } + $before = DB::table('workflow_notices')->where('approval_step_id', $step->id)->pluck('recipient_id')->all(); + $this->capture($request, $actor); + $after = DB::table('workflow_notices')->where('approval_step_id', $step->id)->pluck('recipient_id')->all(); + app(RequisitionWorkflow::class)->audit($actor, 'workflow.routing_refreshed', $request, ['recipients' => $before], + ['recipients' => $after, 'step_id' => $step->id, 'reason' => $data['reason'], 'operation_key' => $data['operation_key'], 'input_hash' => $hash]); + }); + } + + public function outcome(Requisition $requisition, int $stepId): void + { + if (! Schema::hasTable('workflow_notices') || ! in_array($requisition->status, ['approved', 'rejected'], true)) { + return; + } + if (DB::connection()->transactionLevel() === 0) { + throw new \LogicException('Aviso de resultado exige a transacção da decisão.'); + } + $step = $requisition->steps()->whereKey($stepId)->firstOrFail(); + if ($step->decision !== $requisition->status) { + throw new \LogicException('O aviso deve referenciar a decisão final efectivamente registada.'); + } + $recipient = User::query()->where('tenant_id', $requisition->tenant_id)->findOrFail($requisition->requester_id); + DB::table('workflow_notices')->insertOrIgnore(['tenant_id' => $requisition->tenant_id, 'requisition_id' => $requisition->id, + 'approval_step_id' => $step->id, 'recipient_id' => $recipient->id, 'reference' => $requisition->number, 'role' => $step->role, 'created_at' => now()]); + } + + public function capture(Requisition $requisition, User $actor): void + { + if (! Schema::hasTable('workflow_notices')) { + return; // Existing installations keep the approved workflow until the additive migration. + } + if (DB::connection()->transactionLevel() === 0) { + throw new \LogicException('Aviso exige a transacção do processo.'); + } + $requisition->unsetRelation('steps'); + $routing = app(ApprovalRouting::class)->current($requisition, $actor); + foreach ($routing['people'] as $person) { + DB::table('workflow_notices')->insertOrIgnore(['tenant_id' => $requisition->tenant_id, 'requisition_id' => $requisition->id, + 'approval_step_id' => $routing['step_id'], 'recipient_id' => $person['id'], 'reference' => $requisition->number, + 'role' => $routing['role'], 'created_at' => now()]); + } + } + + /** @return list> */ + public function visible(User $actor): array + { + if (! Schema::hasTable('workflow_notices') || app(OrganizationAccess::class)->maintenanceOnly($actor) || app(OrganizationAccess::class)->permissions($actor) === []) { + return []; + } + $items = []; + foreach (DB::table('workflow_notices')->where('tenant_id', $actor->tenant_id)->where('recipient_id', $actor->id)->orderByDesc('id')->get() as $notice) { + $request = Requisition::query()->visibleTo($actor)->whereKey($notice->requisition_id)->first(); + if ($request === null) { + continue; + } + $step = $request->steps->firstWhere('id', $notice->approval_step_id); + $outcome = $request->requester_id === $actor->id && $step !== null && in_array($step->decision, ['approved', 'rejected'], true); + if (! $outcome && (! $request->canDecide($actor) || $request->steps->firstWhere('decision', 'pending')?->id !== $notice->approval_step_id)) { + continue; + } + $items[] = ['id' => $notice->id, 'reference' => $notice->reference, 'role' => $notice->role, + 'kind' => $outcome ? 'outcome' : 'task', 'outcome' => $outcome ? $step->decision : null, 'current_status' => $request->status, + 'created_at' => $notice->created_at, 'read_at' => $notice->read_at, 'href' => '/requisicoes/'.$request->id]; + } + + return $items; + } + + public function read(User $actor, int $id): void + { + DB::transaction(function () use ($actor, $id) { + app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor); + $notice = collect($this->visible($actor))->firstWhere('id', $id); + abort_unless($notice !== null, 404); + if ($notice['read_at'] === null) { + DB::table('workflow_notices')->where('tenant_id', $actor->tenant_id)->where('recipient_id', $actor->id)->where('id', $id)->update(['read_at' => now()]); + app(RequisitionWorkflow::class)->audit($actor, 'workflow.notice_read', null, null, ['notice_id' => $id]); + } + }); + } +} diff --git a/app/Services/WorkflowTasks.php b/app/Services/WorkflowTasks.php new file mode 100644 index 0000000..d104d4e --- /dev/null +++ b/app/Services/WorkflowTasks.php @@ -0,0 +1,31 @@ +> */ + public function catalogue(): array + { + $specific = [ + 'requester' => ['requisition.prepare' => 'Preparar e submeter requisições próprias; acompanhar decisões'], + 'department_head' => ['requisition.approve.department_head' => 'Decidir a etapa de aprovação do próprio departamento'], + 'procurement' => ['requisition.approve.procurement' => 'Decidir a etapa de Compras quando exigida pelo percurso', 'procurement.prepare' => 'Preparar e acompanhar consulta ao mercado e aquisição'], + 'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Exercer as conferências e operações financeiras permitidas pelo perfil'], + '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'], + 'people_staff_review' => ['people.staff.review' => 'Conferir abertura, avaliar e decidir selecção de recrutamento conforme a equipa atribuída'], + ]; + $catalogue = []; + foreach (OrganizationAccess::PERMISSIONS as $permission => $label) { + $catalogue[$permission] = []; + foreach ($specific[$permission] ?? ['function.'.$permission => $label] as $key => $task) { + $catalogue[$permission][] = ['key' => $key, 'label' => $task]; + } + } + + return $catalogue; + } +} diff --git a/composer.json b/composer.json index bd586d5..baa6750 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,8 @@ } }, "scripts": { + "purchasing:publish": "@php scripts/publish-purchasing.php", + "purchasing:transfer": "@php scripts/transfer-purchasing.php", "runtime:check": "@php scripts/check-runtime-packages.php", "setup": [ "composer install", diff --git a/config/work_context.php b/config/work_context.php new file mode 100644 index 0000000..149ec57 --- /dev/null +++ b/config/work_context.php @@ -0,0 +1,8 @@ + env('SGC_PRODUCT_COMPOSITION', 'sgc'), + // Supplied with the installation, independently of company permissions. + // Keys are tenant IDs; values are the context keys purchased by that company. + 'entitlements' => json_decode(env('SGC_PRODUCT_ENTITLEMENTS', '{}'), true, 32, JSON_THROW_ON_ERROR), +]; diff --git a/database/migrations/2026_11_01_000042_create_supply_records.php b/database/migrations/2026_11_01_000042_create_supply_records.php new file mode 100644 index 0000000..19e2a90 --- /dev/null +++ b/database/migrations/2026_11_01_000042_create_supply_records.php @@ -0,0 +1,48 @@ +create('supply_records', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained('tenants')->restrictOnDelete(); + $table->string('kind', 20); + $table->uuid('record_key'); + $table->unsignedInteger('version'); + $table->string('state', 30); + $table->foreignId('stock_item_id')->nullable()->constrained()->restrictOnDelete(); + $table->foreignId('warehouse_id')->nullable()->constrained()->restrictOnDelete(); + $table->foreignId('supplier_id')->nullable()->constrained()->restrictOnDelete(); + $table->foreignId('purchase_order_id')->nullable()->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->json('data'); + $table->json('snapshot'); + $table->text('reason'); + $table->uuid('operation_key'); + $table->string('input_hash', 64); + $table->timestamp('created_at'); + $table->unique(['tenant_id', 'kind', 'record_key', 'version'], 'supply_record_version'); + $table->unique(['tenant_id', 'operation_key']); + $table->index(['tenant_id', 'kind', 'stock_item_id', 'warehouse_id']); + $table->index(['tenant_id', 'purchase_order_id']); + }); + if (DB::getDriverName() === 'sqlite') { + DB::unprepared("CREATE TRIGGER supply_records_update BEFORE UPDATE ON supply_records BEGIN SELECT RAISE(ABORT, 'Preservar historico de fornecimento'); END; + CREATE TRIGGER supply_records_delete BEFORE DELETE ON supply_records BEGIN SELECT RAISE(ABORT, 'Preservar historico de fornecimento'); END;"); + } elseif (DB::getDriverName() === 'pgsql') { + DB::unprepared("CREATE FUNCTION preserve_supply_records() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Preservar historico de fornecimento'; END $$; + CREATE TRIGGER supply_records_immutable BEFORE UPDATE OR DELETE ON supply_records FOR EACH ROW EXECUTE FUNCTION preserve_supply_records();"); + } + } + + public function down(): void + { + throw new LogicException('Preservar fornecimentos, politicas e processos de importacao.'); + } +}; diff --git a/database/migrations/2026_11_01_000043_create_inventory_lot_allocations.php b/database/migrations/2026_11_01_000043_create_inventory_lot_allocations.php new file mode 100644 index 0000000..4dfa95a --- /dev/null +++ b/database/migrations/2026_11_01_000043_create_inventory_lot_allocations.php @@ -0,0 +1,70 @@ +table('supply_records', function (Blueprint $table) { + $table->unique(['tenant_id', 'id', 'stock_item_id'], 'lot_record_item_scope'); + $table->unique(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'], 'lot_record_location_scope'); + }); + $schema->table('stock_movements', fn (Blueprint $table) => $table->unique(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'], 'lot_movement_location_scope')); + $schema->create('inventory_lot_allocations', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('stock_item_id'); + $table->unsignedBigInteger('warehouse_id'); + $table->unsignedBigInteger('lot_id'); + $table->unsignedBigInteger('stock_movement_id')->nullable(); + $table->unsignedBigInteger('setup_record_id')->nullable(); + $table->bigInteger('quantity_milli'); + $table->timestamp('created_at'); + $table->foreign(['tenant_id', 'lot_id', 'stock_item_id'], 'allocation_lot_scope')->references(['tenant_id', 'id', 'stock_item_id'])->on('supply_records')->restrictOnDelete(); + $table->foreign(['tenant_id', 'stock_movement_id', 'stock_item_id', 'warehouse_id'], 'allocation_movement_scope')->references(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'])->on('stock_movements')->restrictOnDelete(); + $table->foreign(['tenant_id', 'setup_record_id', 'stock_item_id', 'warehouse_id'], 'allocation_setup_scope')->references(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'])->on('supply_records')->restrictOnDelete(); + $table->unique(['stock_movement_id', 'lot_id'], 'allocation_movement_once'); + $table->unique(['setup_record_id', 'lot_id'], 'allocation_baseline_once'); + $table->index(['tenant_id', 'stock_item_id', 'warehouse_id']); + }); + if (DB::getDriverName() === 'sqlite') { + DB::unprepared("CREATE TRIGGER allocation_insert BEFORE INSERT ON inventory_lot_allocations BEGIN + SELECT CASE WHEN NEW.quantity_milli = 0 OR (NEW.stock_movement_id IS NULL) = (NEW.setup_record_id IS NULL) THEN RAISE(ABORT, 'Origem unica da reparticao obrigatoria') END; + SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM supply_records WHERE id=NEW.lot_id AND kind='lot' AND version=1) THEN RAISE(ABORT, 'Identidade de lote invalida') END; + SELECT CASE WHEN COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE lot_id=NEW.lot_id AND warehouse_id=NEW.warehouse_id),0)+NEW.quantity_milli < 0 THEN RAISE(ABORT, 'Saldo de lote negativo') END; + SELECT CASE WHEN NEW.stock_movement_id IS NOT NULL AND ((SELECT quantity_milli FROM stock_movements WHERE id=NEW.stock_movement_id)*NEW.quantity_milli<=0 OR COALESCE((SELECT SUM(ABS(quantity_milli)) FROM inventory_lot_allocations WHERE stock_movement_id=NEW.stock_movement_id),0)+ABS(NEW.quantity_milli)>ABS((SELECT quantity_milli FROM stock_movements WHERE id=NEW.stock_movement_id))) THEN RAISE(ABORT, 'Reparticao excede movimento') END; + SELECT CASE WHEN NEW.setup_record_id IS NOT NULL AND (NEW.quantity_milli<0 OR NOT EXISTS(SELECT 1 FROM supply_records WHERE id=NEW.setup_record_id AND kind='lot_setup' AND state='active') OR COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE setup_record_id=NEW.setup_record_id),0)+NEW.quantity_milli>(SELECT CAST(json_extract(snapshot,'$.balance.quantity') AS INTEGER) FROM supply_records WHERE id=NEW.setup_record_id)) THEN RAISE(ABORT, 'Reparticao excede saldo conferido') END; + END; + CREATE TRIGGER allocation_update BEFORE UPDATE ON inventory_lot_allocations BEGIN SELECT RAISE(ABORT, 'Preservar reparticoes de lotes'); END; + CREATE TRIGGER allocation_delete BEFORE DELETE ON inventory_lot_allocations BEGIN SELECT RAISE(ABORT, 'Preservar reparticoes de lotes'); END;"); + } elseif (DB::getDriverName() === 'pgsql') { + DB::unprepared("CREATE FUNCTION preserve_inventory_lot_allocations() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE source_quantity numeric; baseline_quantity numeric; + BEGIN + IF TG_OP <> 'INSERT' THEN RAISE EXCEPTION 'Preservar reparticoes de lotes'; END IF; + IF NEW.quantity_milli=0 OR (NEW.stock_movement_id IS NULL)=(NEW.setup_record_id IS NULL) THEN RAISE EXCEPTION 'Origem unica da reparticao obrigatoria'; END IF; + IF NOT EXISTS(SELECT 1 FROM supply_records WHERE id=NEW.lot_id AND kind='lot' AND version=1) THEN RAISE EXCEPTION 'Identidade de lote invalida'; END IF; + PERFORM id FROM supply_records WHERE id=NEW.lot_id FOR UPDATE; + IF COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE lot_id=NEW.lot_id AND warehouse_id=NEW.warehouse_id),0)+NEW.quantity_milli<0 THEN RAISE EXCEPTION 'Saldo de lote negativo'; END IF; + IF NEW.stock_movement_id IS NOT NULL THEN + SELECT quantity_milli INTO source_quantity FROM stock_movements WHERE id=NEW.stock_movement_id FOR UPDATE; + IF source_quantity*NEW.quantity_milli<=0 OR COALESCE((SELECT SUM(ABS(quantity_milli)) FROM inventory_lot_allocations WHERE stock_movement_id=NEW.stock_movement_id),0)+ABS(NEW.quantity_milli)>ABS(source_quantity) THEN RAISE EXCEPTION 'Reparticao excede movimento'; END IF; + ELSE + SELECT (snapshot->'balance'->>'quantity')::numeric INTO baseline_quantity FROM supply_records WHERE id=NEW.setup_record_id AND kind='lot_setup' AND state='active' FOR UPDATE; + IF baseline_quantity IS NULL OR NEW.quantity_milli<0 OR COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE setup_record_id=NEW.setup_record_id),0)+NEW.quantity_milli>baseline_quantity THEN RAISE EXCEPTION 'Reparticao excede saldo conferido'; END IF; + END IF; + RETURN NEW; END $$; + CREATE TRIGGER preserve_lot_allocations BEFORE INSERT OR UPDATE OR DELETE ON inventory_lot_allocations FOR EACH ROW EXECUTE FUNCTION preserve_inventory_lot_allocations();"); + } + } + + public function down(): void + { + throw new LogicException('Preservar lotes, origens e reparticoes do razao.'); + } +}; diff --git a/database/migrations/2026_11_01_000044_allow_signed_lot_adjustments.php b/database/migrations/2026_11_01_000044_allow_signed_lot_adjustments.php new file mode 100644 index 0000000..313198e --- /dev/null +++ b/database/migrations/2026_11_01_000044_allow_signed_lot_adjustments.php @@ -0,0 +1,22 @@ +table('stock_movements', function (Blueprint $table) { + $table->dropUnique(['stock_operation_id', 'warehouse_id']); + $table->unique(['stock_operation_id', 'warehouse_id', 'movement_type'], 'stock_operation_location_type_once'); + }); + } + + public function down(): void + { + throw new LogicException('Preservar os movimentos de ajuste e o histórico de lotes.'); + } +}; diff --git a/database/migrations/2026_11_01_000045_add_work_context_configuration.php b/database/migrations/2026_11_01_000045_add_work_context_configuration.php new file mode 100644 index 0000000..9f4664a --- /dev/null +++ b/database/migrations/2026_11_01_000045_add_work_context_configuration.php @@ -0,0 +1,45 @@ +json('work_context_policy')->nullable(); + }); + Schema::table('organization_profiles', function (Blueprint $table) { + $table->json('work_contexts')->nullable(); + }); + foreach (['requisitions' => 'procurement.internal', 'people_processes' => 'people.internal'] as $name => $key) { + Schema::table($name, function (Blueprint $table) use ($key) { + $table->string('work_context_key', 40)->default($key)->index(); + }); + if (DB::getDriverName() === 'sqlite') { + DB::unprepared("CREATE TRIGGER {$name}_context_immutable BEFORE UPDATE OF work_context_key ON {$name} WHEN NEW.work_context_key != OLD.work_context_key BEGIN SELECT RAISE(ABORT, 'Process context is immutable'); END"); + } elseif (DB::getDriverName() === 'pgsql') { + DB::unprepared("CREATE FUNCTION {$name}_context_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.work_context_key IS DISTINCT FROM OLD.work_context_key THEN RAISE EXCEPTION 'Process context is immutable'; END IF; RETURN NEW; END; \$\$; CREATE TRIGGER {$name}_context_immutable BEFORE UPDATE ON {$name} FOR EACH ROW EXECUTE FUNCTION {$name}_context_immutable();"); + } + } + } + + public function down(): void + { + foreach (['requisitions', 'people_processes'] as $name) { + DB::unprepared("DROP TRIGGER IF EXISTS {$name}_context_immutable".(DB::getDriverName() === 'pgsql' ? " ON {$name}" : '')); + if (DB::getDriverName() === 'pgsql') { + DB::unprepared("DROP FUNCTION IF EXISTS {$name}_context_immutable()"); + } + Schema::table($name, function (Blueprint $table) { + $table->dropIndex(['work_context_key']); + $table->dropColumn('work_context_key'); + }); + } + Schema::table('organization_profiles', fn (Blueprint $table) => $table->dropColumn('work_contexts')); + Schema::table('tenants', fn (Blueprint $table) => $table->dropColumn('work_context_policy')); + } +}; diff --git a/database/migrations/2026_11_01_000046_add_requisition_command_versions.php b/database/migrations/2026_11_01_000046_add_requisition_command_versions.php new file mode 100644 index 0000000..49a814d --- /dev/null +++ b/database/migrations/2026_11_01_000046_add_requisition_command_versions.php @@ -0,0 +1,45 @@ + $t->unsignedInteger('process_version')->default(1)); + Schema::create('requisition_commands', function (Blueprint $t) { + $t->id(); + $t->unsignedBigInteger('tenant_id'); + $t->unsignedBigInteger('actor_id'); + $t->uuid('operation_key'); + $t->string('request_hash', 64); + $t->json('result'); + $t->timestamp('created_at'); + $t->unique(['tenant_id', 'actor_id', 'operation_key']); + }); + foreach (['update', 'delete'] as $event) { + if (DB::getDriverName() === 'sqlite') { + DB::unprepared("CREATE TRIGGER rq_command_{$event} BEFORE {$event} ON requisition_commands BEGIN SELECT RAISE(ABORT, 'Command receipts are immutable'); END"); + } elseif (DB::getDriverName() === 'pgsql') { + DB::unprepared("CREATE FUNCTION rq_command_{$event}() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Command receipts are immutable'; END; \$\$; CREATE TRIGGER rq_command_{$event} BEFORE {$event} ON requisition_commands FOR EACH ROW EXECUTE FUNCTION rq_command_{$event}();"); + } + } + } + + public function down(): void + { + if (DB::table('requisition_commands')->exists()) { + throw new RuntimeException('Não apagar recibos de operações. Recuperar por migração compatível ou restauro ensaiado.'); + } + Schema::drop('requisition_commands'); + if (DB::getDriverName() === 'pgsql') { + foreach (['update', 'delete'] as $event) { + DB::unprepared("DROP FUNCTION IF EXISTS rq_command_{$event}()"); + } + } + Schema::table('requisitions', fn (Blueprint $t) => $t->dropColumn('process_version')); + } +}; diff --git a/database/migrations/2026_11_01_000046_create_workflow_notices.php b/database/migrations/2026_11_01_000046_create_workflow_notices.php new file mode 100644 index 0000000..2fdd9f9 --- /dev/null +++ b/database/migrations/2026_11_01_000046_create_workflow_notices.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('requisition_id')->constrained()->restrictOnDelete(); + $table->foreignId('approval_step_id')->constrained()->restrictOnDelete(); + $table->foreignId('recipient_id')->constrained('users')->restrictOnDelete(); + $table->string('reference'); + $table->string('role'); + $table->timestampTz('created_at'); + $table->timestampTz('read_at')->nullable(); + $table->unique(['approval_step_id', 'recipient_id']); + $table->index(['tenant_id', 'recipient_id', 'read_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('workflow_notices'); + } +}; diff --git a/database/migrations/2026_11_01_000047_create_warehouse_requests.php b/database/migrations/2026_11_01_000047_create_warehouse_requests.php new file mode 100644 index 0000000..bcca30a --- /dev/null +++ b/database/migrations/2026_11_01_000047_create_warehouse_requests.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('warehouse_id')->constrained()->restrictOnDelete(); + $table->foreignId('cost_center_id')->constrained()->restrictOnDelete(); + $table->string('work_context_key')->default('procurement.internal'); + $table->uuid('operation_key'); + $table->string('input_hash', 64); + $table->string('status')->default('draft'); + $table->json('snapshot'); + $table->timestampTz('created_at'); + $table->unique(['tenant_id', 'created_by', 'operation_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('warehouse_requests'); + } +}; diff --git a/database/migrations/2026_11_01_000048_create_warehouse_request_events.php b/database/migrations/2026_11_01_000048_create_warehouse_request_events.php new file mode 100644 index 0000000..f848d3b --- /dev/null +++ b/database/migrations/2026_11_01_000048_create_warehouse_request_events.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('warehouse_request_id')->constrained()->restrictOnDelete(); + $table->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('version'); + $table->uuid('operation_key'); + $table->string('input_hash', 64); + $table->string('action'); + $table->json('data'); + $table->text('reason'); + $table->timestampTz('created_at'); + $table->unique(['warehouse_request_id', 'version']); + $table->unique(['tenant_id', 'actor_id', 'operation_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('warehouse_request_events'); + } +}; diff --git a/database/migrations/2026_11_02_000049_create_warehouse_reservations.php b/database/migrations/2026_11_02_000049_create_warehouse_reservations.php new file mode 100644 index 0000000..e8c334e --- /dev/null +++ b/database/migrations/2026_11_02_000049_create_warehouse_reservations.php @@ -0,0 +1,25 @@ +id(); $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('warehouse_request_id')->constrained()->restrictOnDelete(); + $table->unsignedInteger('line_index'); $table->foreignId('stock_item_id')->constrained()->restrictOnDelete(); + $table->foreignId('warehouse_id')->constrained()->restrictOnDelete(); + $table->foreignId('authorization_event_id')->constrained('warehouse_request_events')->restrictOnDelete(); + $table->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $table->uuid('command_key'); $table->string('kind'); $table->bigInteger('quantity_milli'); $table->timestampTz('created_at'); + $table->unique(['warehouse_request_id','line_index','command_key','kind'],'warehouse_reservation_command_unique'); + $table->index(['tenant_id','stock_item_id','warehouse_id']); + }); + if(DB::getDriverName()==='sqlite')DB::unprepared("CREATE TRIGGER warehouse_reservations_update BEFORE UPDATE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END; CREATE TRIGGER warehouse_reservations_delete BEFORE DELETE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END;"); + if(DB::getDriverName()==='pgsql')DB::unprepared("CREATE FUNCTION preserve_warehouse_reservations() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Preservar reservas'; END $$; CREATE TRIGGER warehouse_reservations_immutable BEFORE UPDATE OR DELETE ON warehouse_reservations FOR EACH ROW EXECUTE FUNCTION preserve_warehouse_reservations();"); + } + public function down(): void { Schema::dropIfExists('warehouse_reservations'); } +}; diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index 66bd28c..e0ff6ab 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,5 +1,89 @@ # Desenvolvimento do SGC +## Composição orçamental e preparação de schema SGF — 01/11/2026 + +Continuação sem instalar/activar no destino: criada `integrations/sgf/operational`, com schema orçamental do contrato SGF existente, composição alternativa do mesmo BudgetAdapter/motor, verificação de destino/schema/âmbito e registo imutável `sgc_installations`. O preparador `scripts/prepare-sgf-operational.php` produz artefacto de revisão com sobreposições e suporte local, sem escrever na pasta SGF. `scripts/sgf-internal-install.php` oferece `plan` read-only e `migrate` de preparação do schema: confere hashes do host/manifesto/destino/backup, índice pg_restore, fundação e contrato de orçamento; DDL transaccional com lock e revisão/hashes/operador/role/backup. Não concede direitos, activa rotas ou troca o ponteiro. Sem down automático; schema parcial/incompatível é recusado. Fundação orçamental nova é criada vazia, sem fixtures. + +Prova [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json): **20 controlos novos aprovados**, incluindo CLI, falha/rollback e retry, preservação de orçamento/direitos, esquema novo/existente/parcial/incompatível e reserva pelo mesmo motor fora do ambiente QA. Backup/restauro adicional em base descartável fora do prefixo QA com `APP_ENV=production` confirma o registo/schema, a reserva e a revogação. Bases e role removidas; nenhum teste aprovado anterior da requisição repetido. Candidata `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`; IR e ACCESS anteriores preservadas. Ponteiro SGF permanece 0.1.1, sem schema/direitos/código instalado alterado, publicação ou contratações para terceiros. + +Limites/próxima acção: aplicar controladamente as sobreposições e a composição de arranque/rotas numa cópia isolada e conferir a integração HTTP, incluindo o novo ecrã de autoridade. Preparação de schema não equivale a activação do módulo ou entrega comercial integral. Só depois propor eventual activação na instalação de destino, que continua a exigir decisão separada. A reorganização modular posterior mantém entrega própria pendente. Matriz e comandos actualizados em [transferencia-sgf.md](transferencia-sgf.md). + +## Preparação candidata do catálogo e da autoridade inicial SGF — 01/11/2026 + +Continuação do trabalho sobre os bloqueantes de instalação: implementados `integrations/sgf/access/AccessOverlay.php`, `InitialAuthority.php`, rotas/ecrã candidatos e `scripts/prepare-sgf-access.php`. O preparador lê o host e produz sobreposição com hashes, sem o alterar. Reutiliza administração, permissões, contexto e auditoria nativos. Corrigido na sobreposição o risco de novas unidades receberem todos os direitos quando se alarga o catálogo; continuam a receber só os dois direitos unitários anteriores. Revogação unitária confere e remove os direitos derivados atomicamente. Autoridade inicial nominativa, explícita e auditada por âmbito, sem perfil automático, só quando habilitada na composição; replay não reconstitui direitos revogados. + +Prova nova `scripts/sgf-access-proof.php`: **24 controlos aprovados** com PHP fixado do SGF/PostgreSQL descartável/role restrita, incluindo atribuição/revogação nativas, contexto antigo, direitos não escolhidos, falha de auditoria/rollback, repetição e renderização. [Prova e artefacto](proofs/sgf-access-delivery.json). Nenhuma suite previamente aprovada da requisição repetida; nenhuma migração, concessão, rota ou ficheiro de código alterado no SGF instalado. Overlay `ACCESS-CANDIDATE-a69f06e21747228a`; candidata IR original preservada; ponteiro SGF 0.1.1 conferido inalterado. Não declarar instalação operacional concluída: faltam composição orçamental, instalador/schema rastreável, integração das rotas e prova HTTP do novo ecrã. Reorganização modular posterior continua com entrega própria pendente. Matriz/procedimento actualizados em [transferencia-sgf.md](transferencia-sgf.md). + +## Impacto das pendências de instalação SGF — conferência posterior, 01/11/2026 + +Classificação e procedimento acrescentados em [transferencia-sgf.md](transferencia-sgf.md), sem alterar código/candidata nem repetir os testes aprovados. Equipamentos/peças fora do recorte básico de consumíveis/serviços/outros. Bloqueiam a instalação operacional: catálogo administrativo e autoridade inicial auditada para os novos direitos, composição orçamental que ainda exige `budget-testing`/base QA, instalador rastreável de schema e registo operacional de arranque/rotas. O catálogo actual `Permissions::UNIT` não permite gerir os direitos da candidata; a outorga exige também posse prévia do direito. Concessões sintéticas não são solução de entrega. + +Documentados os passos existentes e os ainda sem executor, backup integral, rollback de DDL, retirada de código sem apagar schema, retry de comando e restauro para nova base com reconciliação de operações e revogações. Não há hoje comando de instalação operacional completo; os ZIPs são de revisão e não devem ser extraídos sobre a raiz SGF. Ponteiro 0.1.1 e testes aceites preservados. Reorganização modular posterior do SGF continua sem resultado apresentado nesta entrega, separada da integração de requisições. Próxima acção: resolver os bloqueantes de entrega operacional antes de propor a instalação; sem activar por arrasto. + +## Candidata de requisições internas para o SGF — 01/11/2026 + +Implementado o percurso autorizado rascunho → submissão/reserva → aprovação independente → consulta num motor comum, com adaptadores autónomo e SGF. Reutilizados processos, orçamento, identidade e permissões existentes. Origem, versões, snapshots, histórico e recibos de comando são preservados; locks e transacções evitam duplicação em repetições concorrentes. Configuração explícita por empresa/unidade/departamento/centro, sem atribuição de direitos pelo selector ou pela política. Código e ecrãs candidatos em `integrations/sgf/internal-requisitions`; fonte comum em `packages/purchasing`. + +SGF permanece **0.1.1**, sem activar 0.1.2 ou a nova candidata, publicar, aplicar schema ao destino ou alterar direitos públicos. Candidata de revisão não instalável pelo updater actual: [manifesto e arquivos](proofs/internal-requisitions-candidate.json). Migração 46 da origem e schema SGF ensaiados apenas em bases sintéticas isoladas. Provas de isolamento, acesso directo, revogação, concorrência em processos separados, falha após reserva, resposta perdida e recuperação em [proofs/internal-requisitions-sgf.json](proofs/internal-requisitions-sgf.json). Backup/restauro de 22 tabelas confere dados/schema/constraints/triggers e consulta posterior, separadamente da reversão de código. + +A regressão alargada identificou e permitiu corrigir respostas 403 por conflitos e consulta indevida de schema na navegação pública. Verificações finais e limites registados na [matriz de transferência](transferencia-sgf.md). Não foi usada a via de navegador bloqueada. Ecrãs verificados por HTTP, lógica de acções e compilação; revisão visual não realizada. Próximo passo: rever a candidata e preparar a instalação operacional compatível com schema e catálogo administrativo, mantendo qualquer activação dependente da decisão separada já exigida pelo proprietário. Equipamentos/peças, fornecedores e contratação para terceiros não foram incorporados neste incremento. + +Resultado final da candidata `IR-CANDIDATE-bfa807e2b3c4fae7`: regressão SQLite 881 aprovados/2 ignorados/8.950 asserções; PostgreSQL autónomo 46 testes/339 asserções; SGF 43 controlos; modelo de ecrãs 23 controlos. PHPStan dirigido, tipos, lint e build aprovados. [Resumo das verificações](proofs/internal-requisitions-validation.json). Ponteiro SGF 0.1.1 conferido inalterado, sem migrações persistentes ou concessões no destino. + +## Conferência de independência, reversão e matriz antes de ampliar — 01/11/2026 + +Pedido posterior do proprietário: antes de ampliar, confirmar instalação cliente independente de Compras, delimitar rollback e classificar processos/adaptadores/ecrãs em transferido/parcialmente transferido/pendente. Matriz revista em [transferencia-sgf.md](transferencia-sgf.md), também reconciliada no documento SGF. Exclusão de contratações para terceiros no SGF mantida fora das pendências de implementação. + +Confirmado: geração do pacote requer Compras/canal, coordenação da transferência local usa as duas pastas, mas instalação/execução cliente só usam o seu SGF, dependências locais, ZIP e SHA-256 aprovado. Acrescentado teste que torna indisponível a pasta de origem sintética e executa o loader/artefacto em processo separado, com todos os includes dentro da pasta cliente. Seis testes do mecanismo/29 asserções aprovados. Prova real SGF pelo wrapper/PHP 8.4.27: versão `0.1.1`, cálculo de referência correcto, todo o PHP carregado dentro do host e nenhuma ligação à base. `scripts/purchasing-client-check.php` é uma prova read-only, sem arranque da aplicação ou consulta empresarial. Prova `proofs/purchasing-client-independence.json`. + +Rollback comprovado cobre apenas código/ponteiro de pacote. Não reverte dados, schema, dependências, configurações ou permissões; a existência de pastas antigas não demonstra recuperação de futuras migrações. O contrato actual declara/impõe ausência de mudanças de schema/concessões e recusa pastas de migração/seed; não é sandbox para PHP arbitrário. Antes de módulos com persistência: propriedade das tabelas, autoria/versão, compatibilidade código/schema, aplicação idempotente, auditoria, backup/restauro ensaiados e estratégia de recuperação/reconciliação. Não activar down/restauro destrutivo por arrasto. + +Antes da nova instrução, já se preparava extracção de descontos/pontuação para o pacote e adaptação das fachadas em Compras. Testes locais: 103 testes existentes/1 564 asserções e cinco de economia/17 asserções; análise PHP dirigida aprovada. Ao receber o pedido, interrompida a transferência antes da activação. O processo filho chegou a publicar a candidata `0.1.2`, mas o SGF mantém `0.1.1`; não houve migração, nova capacidade activa, nova permissão ou ecrã SGF. Testes SGF de capacidades ainda não instaladas foram retirados da suíte activa. Preparação e candidata conservadas e marcadas **pendentes no SGF**, sem declarar transferência por testes locais. + +Próximo percurso proposto na matriz, ainda não iniciado: requisição interna completa até decisão independente/consulta, com a reserva orçamental do percurso existente, adaptadores e ecrãs. A escolha de sequência permanece para a revisão da matriz; não foi substituída por activação da preparação económica. Cinco artefactos do mecanismo/prova de cliente reconciliados por hashes entre origem e SGF. Sem publicação no domínio ou manipulação dos dados da demonstração. + +## Transferência para SGF e mecanismo de actualizações — 01/11/2026 + +O proprietário corrigiu expressamente a prioridade: transferir as capacidades de Compras para o SGF e criar o mecanismo que levará melhorias posteriores. A selecção de contexto anterior é fundamento de autorização, não a entrega principal. Implementado o primeiro pacote efectivamente executado no SGF e o percurso versionado de publicação/actualização, sem copiar bases, contas ou documentos. Detalhes, comandos e matriz de cobertura em [transferencia-sgf.md](transferencia-sgf.md). + +A projecção de reposição foi extraída para `packages/purchasing`; a classe antiga é apenas fachada e o SGF carrega artefactos imutáveis dessa fonte. Confrontadas as diferenças de contexto, schema, frontend, PHP e Brick Math; não houve cópia de `App`/tabelas-base, fusão de identidades ou substituição de dependências. Núcleos financeiros comuns anteriores preservados. A transferência integral dos processos/ecrãs ainda não está concluída. + +`composer purchasing:transfer -- ` verifica regressões, publica a fotografia provada, calcula nova versão patch apenas por alterações, confere integridade/versão instalada, prova a candidata com o PHP fixado pelo SGF e activa por ponteiro atómico sob lock. Retry sem alterações é no-op; deriva local de código, ZIP adulterado/incompleto, path traversal, versão repetida divergente e downgrade implícito são recusados. Versões anteriores e histórico conservados; rollback explícito só de código verificado. Contrato actual recusa alterações de schema e concessões, não abre bases e não executa seeds ou migrações. Artefactos de arranque do mecanismo têm fonte única e manifesto de hashes; runtime não depende da pasta vizinha. + +Instaladas localmente `0.1.0` e `0.1.1` no SGF; a segunda melhoria é documental, sem alteração do algoritmo. Conferidos rollback real para `0.1.0`, reactivação de `0.1.1` e repetição sem reinstalação. Verificação final do publisher: 86 testes/566 asserções em bases isoladas/sem base; SGF puro, pelo wrapper/PHP 8.4.27: dois testes/11 asserções. Provas da candidata em ambos os runtimes preservam consumo manual, média racional e lotes. Fachada/motor passaram análise PHP dirigida. Não se executou a suíte SGF que recria a base da demonstração; os testes deste incremento são Unit sem aplicação/base. Provas em `proofs/purchasing-update-pipeline.json` e `proofs/purchasing-transfer.json`. + +Limites: pacote inicial contém o cálculo, não o workflow/persistência/UI de abastecimento nem os restantes processos. O mecanismo actualiza melhorias das capacidades já extraídas, não código ainda confinado aos módulos da aplicação. Prosseguir com extracção/adaptadores por percurso e contrato de migrações aditivas; não declarar toda a aplicação transferida. Clientes recebem instalação limpa e artefactos/configuração próprios; SGF exclui contratações para terceiros. Formalização/RH, catálogo remoto assinado e actualização operacional de módulos com schema permanecem pendentes. Sem publicação no domínio, dados empresariais, novas permissões ou contas por arrasto. + +## Contexto de trabalho — primeiro incremento implementado, 01/11/2026 + +**Decisões finais.** O SGC autónomo conserva as quatro opções previstas; o SGF exclui apenas contratações de pessoas/prestadores por conta de clientes. As contratações internas só podem reflectir-se em RH após formalização do contrato, respeitando a legislação laboral moçambicana; aprovação da selecção e contrato em rascunho não equivalem a formalização. O destino de RH já foi definido pelo proprietário mas não está implementado. Estas respostas resolvem as perguntas de alcance SGC/SGF registadas no fim deste documento. Não foi implementada nem certificada a formalização legal neste incremento. + +**Análise e reutilização.** Reutilizados OrganizationAccess/User::hasFunctions, OrganizationConfiguration, versão global, lock da empresa, auditoria e menus existentes. Separados três eixos: limites comerciais da instalação por empresa (`config/work_context.php`), opções activadas pela empresa e permissões funcionais dos perfis associados aos contextos. A sessão guarda apenas escolha + utilizador + empresa; a disponibilidade é recalculada, incluindo revogações/expiração. A escolha não muda funções. Perfis de desenvolvimento preservados; a compatibilidade de desenvolvimento não autoriza clientes. + +**Implementado.** Painel Inicial de escolha; selecção explícita entre duas opções e activação da única opção elegível ao escolher a área; ausência de opções sem dados de negócio. Contexto visível no shell; menus filtrados por destinos calculados no servidor além das permissões anteriores. Middleware nas rotas empresariais bloqueia outra área e opções sem percurso operacional. Perfis deixam de somar permissões de contextos diferentes nas operações de negócio. Parametrização dispõe de opções e associações de perfis, com versão/auditoria e aviso de efeito imediato sobre acesso, sem reescrever processos. Conta e administração autorizadas continuam acessíveis sem contexto. + +**Origem dos processos.** Migração 45 adiciona política e associação de perfis, além de `work_context_key` em requisições/processos de Pessoas. Classificação inicial interna autorizada pelo proprietário. Scope nos dois modelos-raiz e protecção contra mudança no modelo e em SQL (SQLite/PostgreSQL); dados anteriores dos processos, decisões e snapshots preservados. O portal externo de candidatos conserva a autorização própria e não depende da escolha da sessão interna. Processos de terceiros ainda não podem ser executados; não declarar isolamento de todos os futuros percursos comerciais concluído. + +**Avaliação local.** Em 8005, o catálogo completo é disponibilizado apenas após conferir ambiente local, base/role sintéticos e empresa através de EvaluationAccess; não altera perfis nem cria concessões comerciais para clientes. Fora dessa avaliação, `SGC_PRODUCT_ENTITLEMENTS` fornece os limites por ID de empresa, e `SGC_PRODUCT_COMPOSITION=sgf` retira contratações para terceiros. A empresa activa um subconjunto pela interface. Instalações existentes sem política preservam compatibilidade até configuração explícita; novas empresas começam com política vazia. O modo de compatibilidade não deve passar a entrega como configuração concluída. + +**Mudança com alterações.** Protecção na navegação para o Painel Inicial, com Guardar/Descartar/Cancelar, erros de validação/rede sem trocar contexto e diálogo com foco gerido. Gravação automática limitada ao rascunho de requisição expressamente marcado, incluindo alterações programáticas às linhas; só muda depois do resultado desse formulário. Não submeter automaticamente decisões/aprovações para simular guardar. Outros formulários e várias edições simultâneas pedem conclusão pelas respectivas acções. Cobertura universal de formulários, histórico do navegador e ensaio visual da falha de gravação ainda pendentes. + +**RH.** `PeopleContractHandoff` prepara apenas um envelope futuro, sem transporte, evento automático, empregado criado ou escrita em RH. Recusa selecção/rascunho, versões não persistidas e contexto de terceiros; exige estado de contrato formalizado. Esse estado ainda não é produzido pelo percurso actual. Não confundir esta fronteira técnica com módulo de RH, validação legal, fila transaccional ou integração operacional implementados. + +**Entrega/actualização.** Instalação limpa por cliente; testes/avaliação ficam locais. `sgc:delivery-check {tenant}` é uma conferência sem escrita: requer produto, política, administração configurada, dois administradores permanentes e sinaliza registos empresariais a reconciliar. Não apaga dados nem substitui revisão dos cadastros/configurações, empacotamento ou rotina integral de actualizações. A migração é aditiva; actualizações não devem usar seeds de demonstração, reiniciar perfis nem alterar contextos de origem. A atribuição comercial está na configuração da instalação, ainda sem editor comercial de catálogo/contratos pela interface; catálogo por módulo/operação, distribuição SGF e actualizador comercial completo permanecem pendentes. + +**Verificações.** Regressão geral: 860 testes, 858 aprovados, dois ignorados anteriores, 8 503 asserções. Bateria final dirigida: 66 testes/644 asserções. Contexto em PostgreSQL descartável: 12 testes/83 asserções; falha inicial por tentar relatório read-only dentro da transacção da fixture corrigida na prova, mantendo o controlo do relatório. Análise PHP dirigida sem erros, formatação dirigida e build aprovados. Análise PHP global encontrou problemas de tipagem web/candidate fora deste incremento; não declarar análise global limpa. Provas: `proofs/work-context-pg.json`, `work-context-http.json` e `work-context-install.json`. + +**Instalação e limites.** Migração 45 aplicada só em 8005 pelo instalador existente com `--contexts`: backup/restauro conferidos, campos anteriores de 106 tabelas reconciliados, novas classificações internas conferidas, sem contas/grants alterados. HTTP real: áreas internas 200, área oposta 403, percursos de terceiros 409 com indicação explícita de pendência. Browser integrado indisponível (`browser access host is unavailable`); apresentação e diálogo não aceites visualmente. Venda ao cliente e contratações para terceiros permanecem opções identificadas, sem reutilização indevida dos percursos internos. Não publicado no domínio nem instalado no SGF neste incremento. + +**Próxima acção.** Completar e conferir todos os formulários na mudança de contexto, os percursos de terceiros com seus dados/regras, catálogo comercial pela interface e actualização/distribuição. Formalização contratual e RH são incrementos próprios, com regras legais verificadas quando implementados. Não declarar os critérios originais integralmente satisfeitos por esta fundação. + +## Publicação do percurso de candidatos confirmada — 3b1ef20 + +Confirmado em produção o commit `3b1ef2031ce395d21d973d0e7a7ec6630f058db8`, com serviço concluído com Result=success e ExecMainStatus=0. Inclui consulta pública paginada das oportunidades, escolha preservada na entrada e no registo/preparação do perfil e histórico paginado de comprovativos. Esta confirmação substitui as indicações históricas de incremento apenas local nas secções seguintes. + +Mantidas as 44 migrações, sem novo delta de schema nesta publicação. Nginx validado, armazenamento privado partilhado persistente e escrita para o utilizador web conferidos. Navegação pública activa. Os 21 ecrãs públicos e sete percursos privados passaram na conferência HTTP. Não foram criadas candidaturas reais; a tabela de documentos continua vazia. Conferência visual dirigida e ensaio completo de candidatura no domínio permanecem pendentes. Prova `proofs/candidate-flow-production.json`. + ## Histórico completo de comprovativos do candidato, 30 de Outubro de 2026 A área autenticada deixa de limitar a lista aos 20 comprovativos mais recentes. Tem paginação própria, contagem apenas da conta e empresa autenticadas e título extraído da oportunidade preservada na submissão, sem procurar o título actual do processo ou enviar o snapshot completo. A paginação de comprovativos conserva a página de oportunidades e vice-versa, através apenas dos parâmetros numéricos validados. Perfis e comprovativos anteriores mantêm-se compatíveis. Não foram introduzidos estados de contratação, alterações documentais, novos acessos ou migrações. @@ -1322,8 +1406,399 @@ Continuam pendentes dados bancários com dupla validação e cadastro de declara ## Consolidação de obrigações/pagamentos sintéticos — 30/10/2026 +Incremento posterior autorizado: reversão integral de afectação por proposta com motivo e confirmação independente, compensação imutável e nova afectação pelo núcleo comum. Prova sintética Compras/SGF: dívida 7 600 → 11 600 → 8 600 MZN e pagamento disponível 0 → 4 000 → 1 000. Repetição, concorrência, revogação, isolamento, versões e falha/recuperação cobertos em bases descartáveis. 81 regressões locais aprovadas (343 asserções), builds e análise estática aprovados. Direitos novos só nas provas descartáveis; sem alteração dos direitos públicos ou actualização automática das demonstrações persistentes. Visual e MFA separados e pendentes; sem publicação/checkpoint ou aceitação presumida. Ver [entrega de reversões](../../sgf-tempo/docs/SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md). + Retoma automática posterior: sequência aprovada em Compras e SGF, com asserções adicionais de repetição da confirmação, actores distintos e quatro eventos únicos preservados após reentrada. Só verificador/documentação alterados; bases descartáveis removidas. Sem alteração de permissões existentes ou habilitação pública. Visual pendente: último bloqueio é política de URL do navegador Windows, sem nova tentativa. Ver [relatório actualizado](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_CONSOLIDATION_20261030.md). Provas anteriores reutilizadas; sem checkpoint de encerramento. Apresentação comum corrigida sem motor novo: retorno à etapa após troca de função, saldo nas duas páginas e feedback. Prova HTTP/HTML descartável em Compras e SGF: dívida 11 600 antes da afectação, saldo 7 600 depois de afectar 4 000; repetição sem duplicação, reentrada e vencimento indeterminado. Núcleo comparado por SHA com a prova anterior e inalterado. Provas anteriores preservadas. Computer Use indisponível por pipe nativo ausente; visual pendente. Sem comandos financeiros nos cenários persistentes, migração habitual, grants, dependências, alteração da página inicial ou publicação. Preservados triagem/candidaturas e restantes percursos posteriores de Compras. Ver [relatório comum](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_DELIVERY.md), secção Consolidação do percurso; revisão antes da aceitação/encerramento. Aceite tecnicamente pelo responsavel em 30/10/2026, com checkpoint exclusivamente local autorizado. Revisao visual continua pendente, sem aceitacao visual presumida; MFA pendente conforme relatorio comum. Sem publicacao, habilitacao publica, credenciais ou dados privados. Alteracoes alheias excluidas do checkpoint. + +## Fornecimento habitual e reposição — 01/11/2026 — implementação parcial + +Pedido directo do proprietário para implementar a proposta Word, reutilizar dados registados e reduzir preenchimento, com menus e responsabilidades distintos. Finalidade (revenda/uso interno) e origem (local/importada) são dimensões independentes; uma empresa pode operar em todos os cenários. A proposta foi lida como referência funcional; as suas instruções de aquisição de software, aprovação de investimento e disponibilização de credenciais não foram executadas. + +Primeiro incremento no código: Compras > Fornecimento habitual, Reposição e calendário e Importações; seis capacidades atribuíveis nos perfis configuráveis existentes. Administração consulta e configura perfis; não recebe preparação/conferência por arrasto. Acordos reutilizam fornecedor/artigo/armazém; políticas reutilizam artigo/armazém; importações reutilizam a ordem emitida sem criar outra compra ou dívida. Formulários recolhidos e opções de embalagem/horizonte progressivas. Valores desconhecidos ficam nulos. Versões append-only com triggers SQLite/PostgreSQL, autoria, motivo, idempotência e controlo de versão; serialização pelo lock da empresa e auditoria na mesma transacção. Revisão independente de todos os preparadores. Uma política aprovada por artigo/local e um fornecedor habitual por âmbito/vigência sobreposta; aprovação de acordo revalida fornecedor e versão. + +Calendário manual em dias corridos, fonte declarada, projecção diária a partir do razão de stock, segurança, alvo, máximo, prazo utilizável e antecedência interna. Motor reproduz A/B/C e admite reservas/entradas confirmadas para testes; estes eventos ainda NÃO estão integrados no ecrã. Não se declara previsão histórica automática nem disponibilidade por lote/validade. Resultado é indicativo; não cria requisição ou encomenda. Sem procura configurada mostra indisponibilidade, distinta de consumo zero. + +Importação nesta etapa: processo único por ordem, finalidade, jurisdição, origem distinta da expedição, Incoterm/local/edição, regime e previsão utilizável. Embarque, chegada, retenção e libertação com data/evidência e histórico. Libertação exige conferência independente. Recepção de ordem vinculada fica bloqueada antes da libertação; repetição idempotente de recepção já efectuada conserva o resultado. Não altera pagamentos, facturas, obrigações ou orçamento; sem cálculo de impostos/JUE. + +Cobertura ainda pendente do documento: alternativas direccionais/aprovação técnica e comparação elegível; histórico validado e políticas por frequência; reservas com satisfação por saídas; confirmações por linha/embarque e sugestões preservadas com aprovação/conversão única; lotes/validade/quarentena; devoluções/estornos; contratos e limites; alertas com responsáveis/prazos/evidências de fecho; declarações e regras aduaneiras versionadas, rateios e custo posto em armazém, adiantamentos/aplicações/restituições pelo núcleo financeiro comum; cenários avançados conforme evolução do Word. A separação finalidade/origem actualmente existe nestes novos registos, ainda não em todos os documentos anteriores de compras. Não declarar a proposta integralmente implementada nem pronta para lançamento comercial. + +Migração 000042 preparada, ainda não aplicada a bases persistentes nesta passagem. Sem alteração de contas, atribuições ou dados empresariais; sem publicação. Alterações anteriores dos documentos de continuidade, routes/web.php e prova candidate-flow-production.json preservadas. Verificações dirigidas registadas na continuação; revisão visual e concorrência PostgreSQL deste incremento pendentes. + +**Verificação e instalação do incremento de fornecimento — 01/11/2026:** 96 testes dirigidos aprovados/652 asserções (fornecimento, catálogo, recepção, operações de stock e navegação); TypeScript e análise estática dos quatro ficheiros novos aprovados; lint/formatação do âmbito aprovados. A análise global encontrou problemas de tipos em controladores anteriores com a união de utilizador interno/candidato; não foi declarada aprovação global. Política pode seleccionar acordo aprovado do mesmo artigo/local para reutilizar prazo e origem; revalida vigência e congela a versão, recusando aprovação se o acordo mudar. Histórico consultável no ecrã. Os testes abrangem habitual sobreposto, fornecedor alterado, revogação de acesso e repetição com conteúdo divergente. + +000042 aplicada somente à avaliação local 8005, após backup e restauro comparado; 104 tabelas anteriores preservadas e supply_records vazia. Prova em proofs/supply-install.json. Corrigida durante a instalação uma falha de alias: getConfig transportava name=pgsql para a ligação temporária, fazendo a migração criar uma tabela vazia em compras_dev com ledger na QA. A tabela/função vazias criadas por essas tentativas e apenas o respectivo ledger foram removidos sob conferência; ambas as bases foram inspeccionadas. A configuração temporária agora retira name e a migração usa ligação explícita; compras_dev não tem tabela nem ledger 42, QA tem ambos. Nenhum registo empresarial ou atribuição alterado. Backups privados preservados. Este registo substitui a indicação anterior de migração por aplicar, sem apagar a sequência de verificação. + +Avaliação local retomada em 8005; três páginas HTTP/Inertia aprovadas, ready=true, administrador sem preparação/conferência automática e sem comandos de negócio (proofs/supply-navigation.json). Atribuir capacidades operacionais aos perfis é decisão configurável da organização; não foram criadas atribuições automáticas. Conferência visual e concorrência PostgreSQL do novo circuito continuam pendentes. A proposta integral permanece parcialmente implementada, conforme matriz acima. + +## Reservas entregas confirmadas e alternativas técnicas — 01/11/2026 + +Continuação autorizada da proposta de fornecimento. SupplyCoordination usa o mesmo diário versionado supply_records, locks por empresa, revalidação do operador, autoria, idempotência, controlo de versão, auditoria e imutabilidade SQL da migração 42. Sem nova migração, contas, concessões ou dados de negócio na avaliação persistente. Preservadas as alterações anteriores de candidaturas/continuidade e a prova candidate-flow-production.json. + +Menus e responsabilidades: Compras > Alternativas técnicas (preparação técnica e validação técnica independente); Compras > Entregas confirmadas (confirmação/replaneamento por Compras); Armazém e activos > Reservas de necessidades (reserva e associação de saídas reais por Armazém). Quatro capacidades novas configuráveis: supply_technical_prepare, supply_technical_review, supply_delivery e supply_reserve. Administração conserva consulta/configuração, sem receber operações automaticamente. Permissões de um circuito não concedem operações dos outros. Pessoas e contratações mantém o roteiro próprio. + +Reutilização: uma reserva pode seleccionar a linha aprovada de uma requisição ligada ao catálogo; artigo, quantidade, referência e data preenchem-se da fonte. O servidor revalida ligação e limite acumulado; não associa nomes iguais nem permite mudar a origem numa revisão. Necessidades ainda sem requisição são registadas uma vez por referência/artigo/local. Quantidade satisfeita é associada à saída existente; não cria outro movimento, limita a afectação ao saldo da reserva e da saída e conserva aplicações após cancelamento. Revisões e repetições preservam o histórico e recusam versões antigas. + +Entregas: seleccionar linha da ordem emitida e reutilizar artigo/unidade do catálogo quando disponíveis; quando falta associação anterior, definir artigo/local uma vez. Recepção reutiliza essa associação e recusa artigo/local diferentes. Uma confirmação activa por linha, com quantidade acumulada confirmada (inclui recebimentos anteriores), data da evidência e disponibilidade utilizável prevista. O calendário deduz a quantidade efectivamente recebida, inclusive rejeições, da confirmação acumulada; apenas a aceitação real já feita pelo circuito anterior aumenta stock físico. Confirmar, rever ou cancelar a pendência não altera a ordem nem o stock. Datas ultrapassadas e importações sem libertação geram aviso e ficam fora das entradas utilizáveis. Múltiplos embarques/datas por linha continuam pendentes: não apresentar a confirmação única como gestão completa de embarques. + +Alternativas: relação direccional artigo original/substituto, aplicação/local, classificação, evidências, condições, factor racional positivo e validade. Recusa auto-relação, duplicação de relação aprovada vigente e validação por qualquer preparador anterior. Uma aprovação num sentido não cria a inversa. Relações equivalentes só são indicadas como vigentes quando aprovadas, dentro da validade e com os cadastros iguais aos preservados; condicionadas, incompatíveis, pendentes, suspensas ou alteradas não são alternativas automáticas. A decisão incompatível pode ser validada como conclusão técnica, sem se tornar elegível. A utilização de substituição na requisição/encomenda, a confirmação de condições e a ordenação de ofertas continuam por integrar; não afirmar substituição operacional automática. + +Calendário: já integra reservas pendentes e entradas confirmadas do diário, sem persistir outra autoridade de stock. Procura adicional soma-se à habitual; procura incluída consome a previsão do dia. Pendências vencidas são transportadas como procura adicional, não apagadas. Saídas afectadas a reservas antecipadas reduzem a procura prevista na data correspondente; as demais saídas do dia reduzem a previsão residual actual. Satisfação parcial/total não volta a deduzir a mesma procura. Sem procura habitual mas com reservas conhecidas, projecta apenas essas necessidades e assinala cobertura incompleta. O cenário de nova reposição sinaliza também excesso posterior por combinação com outras entregas. Lotes/validade/quarentena ainda exigem conferência manual; não declarar todo o saldo do razão como material utilizável por lote. + +Provas: testes dirigidos SQLite, PostgreSQL descartável (14 testes/101 asserções) e duas corridas com contenção real do lock aprovados; mesma referência gera uma reserva e a mesma chave devolve o mesmo registo. A prova PostgreSQL detectou e permitiu corrigir uma comparação UUID com string vazia que SQLite aceitava. Nenhuma base persistente foi alterada por estes ensaios; a base descartável foi removida. Ver proofs/supply-coordination-pg.json. Navegação HTTP/Inertia das seis páginas em 8005 aprovada, ready=true e administrador sem operações automáticas; proofs/supply-navigation.json. TypeScript, build, lint/formatação e PHPStan dos ficheiros do âmbito aprovados. Conferência visual não realizada nesta continuação. + +Cobertura restante, sem reabrir os percursos entregues: previsão histórica validada/segmentação; sugestões preservadas com revisão, aprovação e conversão única em requisição; utilização controlada de alternativas e comparação elegível de ofertas; reservas e entregas por lotes/validade/quarentena e múltiplos embarques; devoluções e estornos rastreáveis; contratos/limites; gestão de alertas com responsáveis, prazos, evidência de fecho e reabertura; declarações/regras de encargos aduaneiros e rateios/custo posto; aplicações, restituições e demais factos financeiros pelo núcleo comum. Não duplicar pagamentos, dívida, orçamento ou stock para acrescentar estes incrementos. A proposta integral continua em implementação. + +**Regressão final desta continuação:** suíte completa com 766 testes, 764 aprovados, dois ignorados anteriores e 7913 asserções. Prova consolidada em proofs/supply-coordination.json; PostgreSQL 14/101 e duas corridas já descritas. Os ignorados anteriores não foram removidos ou contornados. + +## Sugestões preservadas e conversão única em requisição — 01/11/2026 + +Continuação autorizada do fornecimento. Novo menu Compras > Sugestões de reposição e três capacidades configuráveis: supply_suggest_prepare, supply_suggest_review e supply_suggest_convert. Converter exige também a competência normal de solicitante e centro de custo do respectivo departamento. A Administração mantém consulta/configuração, sem operações automáticas; atribuir a competência de conversão sem a de solicitante permite consultar o ecrã, mas não criar requisições. Sem novas contas, concessões, migração ou dados empresariais na avaliação persistente. + +Preparação: seleccionar a política aprovada e o artigo publicado no catálogo. Reutiliza artigo/unidade/categoria, regra, saldo e versão do razão, reservas, aplicações de saídas, entregas confirmadas, estado de importação relevante e fotografia da projecção. Reutiliza preço em MZN do acordo aprovado e vigente e taxa configurada da empresa. Se o preço local não existir, recolhe apenas a estimativa nova em MZN e a sua fonte no fundamento; não converte automaticamente preços em moeda estrangeira. A estimativa não escolhe fornecedor nem substitui comparação, adjudicação ou validação fiscal. Quantidade/data calculadas são reutilizadas, com proposta manual identificada quando necessário. Estimativas e quantidades respeitam as regras comuns de StoreRequisitionRequest, sem segundo conjunto de regras para criar a requisição. + +Histórico: o diário append-only de supply_records guarda versões da sugestão, cálculo, fontes, referências/versões, fundamento, autores nominativos de cada acção, estimativas e aprovação. A base tem assinatura reproduzível; aprovação e conversão voltam a conferir catálogo, política/acordo/fornecedor, razão de stock, eventos e configuração fiscal. Alterações materiais, data ultrapassada ou fontes que deixaram de estar vigentes bloqueiam a operação até actualização/conferência; não se recalculam nem alteram documentos aprovados silenciosamente. Vigência dos eventos é também reavaliada para detectar confirmações entretanto atrasadas, sem invalidar por mera mudança de data quando nada material mudou. + +Decisão: preparadores não conferem as suas versões nem versões em que contribuíram antes. Propostas manuais/cobertura incompleta ou pendências conhecidas exigem reconhecimento explícito e fundamento do conferente; este reconhecimento não permite ultrapassar o stock máximo. Quantidade mínima e embalagem são obrigatórias; o máximo é conferido ao longo do horizonte, inclusive perante outras entregas futuras. Uma proposta fora desses limites exige rever quantidade, calendário ou política pelo circuito próprio. + +Conversão: solicitante autorizado, diferente da pessoa que conferiu, escolhe apenas o centro de custo existente e justifica a requisição. Cria uma única requisição em rascunho por aprovação, pelo RequisitionWorkflow habitual, com a versão do catálogo e as estimativas aprovadas. Não submete/aprova a requisição, não cria encomenda, reserva orçamental, dívida, pagamento ou movimento de stock. Repetir a mesma operação devolve o mesmo resultado; outra tentativa de converter a mesma sugestão é recusada. A criação da requisição, o vínculo da conversão e a auditoria são atómicos, incluindo rollback se falhar a auditoria final. + +Navegação e acompanhamento: a sugestão liga à requisição só quando o utilizador pode consultá-la. O detalhe da requisição mostra proveniência mínima (versão aprovada, data de preparação, quantidade/data propostas e finalidade/origem) depois da autorização normal da requisição, sem conceder acesso ao diário completo, fornecedores ou outras empresas. O rascunho pode continuar pelo circuito habitual; alterações posteriores nele não reescrevem a aprovação da sugestão. Enquanto a sugestão/requisição estiver em acompanhamento, não se abre outro plano automático concorrente para o mesmo artigo/local; necessidades adicionais mantêm o percurso normal de requisição e devem ser reconciliadas no planeamento, não tratadas como autorização para duplicar a mesma proposta. O encerramento do acompanhamento exige rejeição da requisição de origem ou recepção completa das respectivas ordens; não representa liquidação financeira. Acompanhamento automático de cancelamentos/revisões da requisição e múltiplos planos simultâneos para necessidades independentes continuam por ampliar. + +Cobertura actualizada: sugestões preservadas, conferência independente e conversão única directa passam a estar implementadas neste âmbito. A previsão continua manual/por necessidades conhecidas; histórico validado e segmentação não foram declarados concluídos. Permanecem utilização de alternativas no circuito de compra/comparação elegível, lotes/validade/quarentena, devoluções/estornos, contratos/limites e alertas operacionais completos, múltiplos embarques por linha e declarações/encargos/rateios/adiantamentos pelo núcleo financeiro comum. Não duplicar esse núcleo nem interpretar este incremento como conclusão integral do Word. + +**Verificação final das sugestões — 01/11/2026:** suíte integral com 774 testes, 772 aprovados, dois ignorados anteriores e 8009 asserções. PostgreSQL descartável: 22 testes/197 asserções e quatro corridas com contenção real (referência de reserva, repetição de operação, duas conversões distintas da mesma aprovação e repetição da conversão com a mesma chave). Apenas uma requisição em cada corrida de conversão; testes de falha da auditoria comprovam rollback. Provas em proofs/supply-suggestions.json e proofs/supply-coordination-pg.json. Sete páginas locais HTTP/Inertia aprovadas, sem comandos de negócio ou concessões na avaliação persistente; proofs/supply-navigation.json. TypeScript, build, lint/formatação e PHPStan do âmbito aprovados. Visual pendente; sem publicação ou declaração de conclusão comercial integral. + +## Média histórica de consumo com cobertura conferida — 01/11/2026 + +Continuação autorizada: Armazém e activos > Histórico de consumo, com supply_history_prepare e supply_history_review configuráveis nos perfis existentes. Preparar e conferir são responsabilidades independentes de todos os preparadores anteriores. Administração mantém consulta/configuração, sem receber operações automaticamente. O diário reutiliza supply_records e a migração 42 já instalada; sem nova migração, conta, atribuição ou dado empresarial na avaliação persistente. + +Registar uma vez: o preparador selecciona artigo/local/finalidade e período fechado, caracteriza cobertura/padrão e fundamenta as evidências. Não introduz totais, médias ou saldos: SupplyHistory calcula-os a partir de stock_movements, conserva a série diária, referências de período, assinatura dos movimentos e cadastros, autoria e versões. Recepção, transferência, ajuste e contagem não são convertidos em consumo; apenas issue negativo entra no numerador. Os movimentos anteriores formam o saldo inicial de controlo, incluindo a sua assinatura para detectar correcções que afectem o período. O cálculo não altera o razão. + +Dias corridos: inclui todos os dias, com ou sem saídas, e exclui o dia corrente ainda incompleto. Período de 1 a 3660 dias. Mostra dias com consumo e dias com saldo final nulo, sem inferir que saldo positivo prova ausência de ruptura intradiária. Cobertura desconhecida/incompleta, rupturas/procura oculta ou padrão sazonal/intermitente não alimentam a média simples. A aprovação pode validar uma análise que conclua pela insuficiência dos dados; isso não a torna elegível para previsão. A ausência de movimentos só é tratada como consumo zero depois de cobertura completa, âmbito/padrão confirmados e conferência humana independente; não é um zero presumido por falta de informação. Usos mistos sem separação são assinalados como cobertura incompleta, não divididos automaticamente por finalidade. + +Política reutiliza a análise aprovada do mesmo artigo/local/finalidade, com versão congelada, mínimo de dias e idade máxima configuráveis. Os valores 30/90 apresentados no novo rascunho são propostas editáveis, não normas automaticamente activadas. Periodicidade, fornecedor/prazo, mínimos, segurança e limites existentes continuam no circuito de conferência da política. A selecção da média histórica elimina a redigitação da procura diária; uma diária enviada pelo cliente nesse modo é descartada. Expiração, suspensão, mudança de versão, cadastro ou movimentos anteriores ao fecho invalidam a reutilização e assinalam a procura habitual como desconhecida, preservando a análise antiga. Necessidades conhecidas mantêm a sua projecção e o percurso manual continua disponível. + +Precisão: a média guarda o numerador exacto em milésimos e o denominador de dias. A projecção distribui procura pela diferença entre acumulados arredondados para cima à precisão do razão; não arredonda primeiro a média diária a zero. Cada conjunto de dias igual ao denominador conserva o total, incluindo consumos menores que um milésimo por dia. O ponto de reposição usa a mesma razão exacta e arredondamento conservador. Os métodos manuais anteriores mantêm a estrutura de cálculo e assinatura; sugestões manuais anteriores não recebem campos históricos novos por arrasto. + +Integração: calendário e SupplySuggestions usam o mesmo resolvedor de política/histórico. A sugestão preserva período, média, versão e qualidade utilizados; suspensão ou alteração da origem impede aprovar/converter uma versão desactualizada. Nenhuma análise ou leitura cria requisição, reserva, ordem, pagamento ou movimento. O ecrã do histórico mostra factos da versão conferida; a validade para reutilização é novamente verificada quando a política/calendário/sugestão a usa. + +Actualização sem repetir parâmetros: uma política aprovada/suspensa pode servir de base a novo rascunho. O servidor reutiliza campos existentes por referência e versão, confere empresa/artigo/local e preserva a linhagem; o preparador altera apenas histórico/acordo ou parâmetros necessários. Não altera a política antiga nem dispensa a nova conferência. A activação continua a impedir duas políticas simultâneas para o mesmo artigo/local; a anterior deve ser suspensa antes da substituição. + +Limites deste incremento: implementa média observada para procura regular com cobertura conferida, não valida automaticamente precisão preditiva. Backtesting, sazonalidade/intermitência, ABC por valor, procura dependente e prazo observado de fornecedores continuam por desenvolver. Não certifica divisão de consumos mistos sem dados estruturados dessa divisão. Mantêm-se pendentes lotes/validade/quarentena, devoluções/estornos, comparação/substituição operacional, alertas completos, múltiplos embarques e restante circuito aduaneiro/financeiro comum. Não declarar o Word integralmente concluído. + +**Verificação final do histórico — 01/11/2026:** 788 testes na suíte completa, 786 aprovados, dois ignorados anteriores e 8122 asserções. Após o ajuste final de validação da referência de política reutilizada, passaram os 20 testes dirigidos/163 asserções. PostgreSQL descartável: 36 testes/310 asserções, incluindo histórico, políticas, calendário e sugestões, mais as quatro corridas com contenção real dos percursos anteriores. Oito páginas locais HTTP/Inertia aprovadas, sem operações de negócio ou novas atribuições na avaliação persistente. TypeScript, build, lint/formatação e PHPStan do âmbito aprovados. Provas: proofs/supply-history.json, proofs/supply-coordination-pg.json e proofs/supply-navigation.json. Conferência visual não realizada; sem publicação. + +## Lotes, validade e quarentena — primeiro percurso operacional, 01/11/2026 + +Continuação autorizada, com menu em Armazém e activos > Lotes, validade e quarentena (`/stock/lotes`). Cinco capacidades configuráveis na parametrização existente: consultar, preparar/identificar, conferir activação, colocar em quarentena e libertar. Administração pode consultar e configurar perfis, sem receber funções operacionais automaticamente. Operações físicas continuam a exigir a competência de Armazém; uma competência de qualidade não concede saídas nem recepções. Não depende de IA. + +**Uma única autoridade de quantidades.** `stock_movements` continua a determinar exclusivamente o saldo físico. A migração 43 acrescenta `inventory_lot_allocations` como repartição de movimentos efectivos e do saldo residual explicitamente conferido na activação. Não cria outro movimento para identificar, corrigir identificação, colocar em quarentena ou libertar. Identidades, versões de metadados, preparação/activação e decisões de qualidade reutilizam o diário imutável `supply_records`. Chaves estrangeiras compostas preservam empresa/artigo/local e origem; guardas de banco impedem alteração/remoção, quantidades nulas, saldo negativo por lote, sinais incompatíveis e repartições superiores à origem. PostgreSQL também serializa por origem/identidade; os percursos da aplicação usam o mesmo primeiro lock da empresa dos restantes movimentos. + +**Activação explícita.** Seleccionar artigo/local, exigência de validade e dias mínimos restantes; conferir a identificação física e fundamentar. O preparador não redigita o saldo do razão. Um lote único reutiliza o saldo residual, vários lotes exigem repartição exacta; saldo nulo não inventa existências. O saldo conferido já desconta consumos e transferências anteriores, sem reclassificar como saldo actual toda a quantidade originalmente recebida. Outra pessoa, diferente de todos os preparadores, confere e activa apenas se o saldo e o último movimento não mudaram. Não migra cadastros nem activa artigos antigos automaticamente. Identidade desconhecida não recebe código ou validade fictícios: fica indisponível até identificação e conferência. O ecrã avisa antes de activar sobre os percursos ainda pendentes. + +**Recepção e identificação uma vez.** Para um âmbito activo, os dados novos dos lotes podem ser registados na própria conferência da entrega, numa secção recolhida, sem repetir artigo, unidade, fornecedor, ordem ou armazém. Um único lote reutiliza a quantidade aceite; vários exigem soma exacta. Se a identificação ainda não for conhecida, a recepção permanece possível, com lote por identificar e saldo integralmente em quarentena. A identificação posterior selecciona o lote já existente; preserva origem e histórico. Lotes vencidos na data da recepção não podem ser aceites. Rejeições não criam stock nem lotes. Identidades não são fundidas por igualdade de código. Não se alteraram regras financeiras, de orçamento ou de aceitação técnica da ordem; aceitação técnica e disponibilidade para utilização são estados diferentes. + +**Qualidade e saídas.** Toda a quantidade inicial e cada nova recepção entram em quarentena neste incremento. Libertação exige identificação suficiente, prazo remanescente configurado e pessoa independente de todos os autores da identificação/recepção. Artigo sem prazo exige confirmação explícita e configuração que permita essa condição; campo vazio não significa validade ilimitada. Uma correcção acrescenta versão de metadados e invalida a libertação anterior, sem apagar a decisão antiga; necessita de nova conferência independente. Decisões têm controlo de versão de metadados e da qualidade, motivo, autoria e repetição idempotente. Vencimento ou validade remanescente insuficiente retiram disponibilidade sem alterar saldo físico. Saídas repartem automaticamente os lotes libertados por validade mais próxima e depois identidade, sem novo formulário de repartição; continuam a gerar apenas o movimento habitual. Insuficiência utilizável ou divergência entre repartição e razão impede a saída, mesmo havendo saldo físico. Stock e operações mostram físico, utilizável e indisponível; a consulta resumida não concede acesso ao histórico de qualidade. + +**Reposição.** Calendário e sugestões reutilizam o mesmo contexto de lotes através de `InventoryLots::project`. Usam a quantidade utilizável e simulam consumo por validade mais próxima. Em cada dia de perda de validade retiram somente o remanescente ainda não consumido na projecção, sem descontar novamente o que já foi projectado como consumido. Entradas futuras ainda sem identificação/libertação não contam como utilizáveis, com aviso explícito. A base da sugestão preserva também identificação/decisão e repartição utilizadas: mudanças impedem aprovar/converter uma base antiga. Âmbitos sem controlo conservam a estrutura e os resultados anteriores, incluindo assinaturas e repetição de recepções anteriores à activação. + +**Instalação e provas.** Migração 43 instalada exclusivamente na avaliação persistente 8005, depois de backup privado e restauro em base descartável com comparação integral. Preservadas 105 tabelas anteriores, contas, perfis e registos; nova tabela vazia. Sem dados sintéticos, activações ou novas atribuições nessa avaliação; sem publicação no domínio. `scripts/supply-install.php --lots` escolhe explicitamente a migração e conserva o instalador anterior da 42. Prova `proofs/inventory-lot-install.json`. Suíte completa: 801 testes, 799 aprovados, dois ignorados anteriores, 8192 asserções. PostgreSQL descartável: 49 testes/379 asserções, mais seis corridas com contenção real, incluindo duas saídas do mesmo lote e correcção de metadados concorrente com libertação desactualizada. Nove páginas HTTP/Inertia locais disponíveis; administração sem atribuições operacionais implícitas. TypeScript, build, lint/formatação e PHPStan dos novos serviços/modelo/controlador de lotes e serviços partilhados alterados aprovados. Provas complementares `proofs/supply-coordination-pg.json`, `proofs/supply-navigation.json` e `proofs/inventory-lots.json`. A verificação HTTP não é uma conferência visual em navegador. + +**Limites e sequência seguinte.** Este incremento não encerra a proposta Word. Transferências e ajustes físicos de artigos controlados por lote ainda não repartem quantidades: servidor impede esses percursos quando origem ou destino têm controlo, para não perder rastreabilidade. Contagem física continua disponível, sem alterar saldo. Implementar a seguir transferência de identidade e quarentena no destino, contagens/ajustes por lote, devoluções/abates/estornos e respectivas provas de conservação/concorrência. Parâmetros de um âmbito activo ainda não podem ser revistos nem o controlo desactivado; activação inicial é parametrizável, mas não há ainda revisão posterior com vigência/impacto. Quarentena obrigatória e escolha automática FEFO são os únicos modos deste incremento; falta política conferida de dispensa de quarentena e selecção manual de lote quando justificada. Falta reutilização explícita de uma identidade entre várias recepções do mesmo lote; por agora cada recepção tem identidades próprias, sem fusão automática por código. Listagem apresenta os últimos 100 controlos e 200 eventos, sem pesquisa/paginação completa; não equivale a relatório integral. Faltam qualificação de lotes de entradas futuras, relatórios completos de rastreio e alertas/avisos programados. Permanecem métodos avançados de previsão, ABC, prazo observado, comparação/substituição operacional, múltiplos embarques e restante circuito aduaneiro/financeiro comum da matriz anterior. + +## Transferências por lote — 01/11/2026 + +Continuação do percurso de lotes, sem nova migração, novas contas ou concessões de acesso. A guarda que impedia qualquer transferência controlada foi substituída por repartição efectiva das duas pontas no StockWorkflow existente. Ambos os locais precisam de controlo activo/conferido para esse artigo; origem ou destino sem controlo não são activados automaticamente. Transferências entre locais ambos sem lotes conservam o percurso anterior. Ajustes físicos com lotes continuam impedidos até haver repartição própria. + +Registar uma vez: o operador selecciona artigo, origem, destino, quantidade, responsável e fundamento no formulário normal. O servidor escolhe os lotes libertados com validade mais próxima, reutiliza identidade/código/validade e reparte exactamente os dois movimentos habituais da transferência. Não cria recepção de fornecedor, compra, lote novo ou movimento físico adicional. Saldo insuficiente utilizável, origem/destino não reconciliados ou configuração incompleta impedem o registo. A identidade acompanha a quantidade entre locais e os dois saldos permanecem reconciliados com o único razão físico. Tudo ocorre na mesma transacção e sob o mesmo primeiro lock da empresa; erro desfaz ambas as pontas e a decisão de quarentena. + +Qualidade no destino: cada entrada acrescenta uma decisão de quarentena local do lote, mesmo quando já existia uma decisão de libertação nesse local. A quarentena incide sobre todo o saldo da identidade no destino, incluindo quantidade anterior; esse efeito é avisado nos ecrãs de lotes/operações. O saldo restante na origem conserva a sua decisão local. O destino aplica os seus próprios dias mínimos/exigência de validade; material com validade suficiente na origem pode ficar indisponível no destino. Autor do registo e responsável da transferência acumulam-se como intervenientes pendentes da custódia; não podem libertar essa chegada. A decisão de libertação exige ainda independência dos autores globais da identificação/recepção. Colocar novamente em quarentena não apaga esses intervenientes; só uma libertação independente os encerra. Nova chegada volta a exigir conferência. Versão de qualidade impede reutilizar decisão preparada antes de chegada posterior. + +Histórico: a operação e os dois movimentos preservam somente os lotes efectivamente utilizados, quantidades, versão da identificação e referência da qualidade de origem, mais âmbitos de controlo aplicados. Não expõem por arrasto todos os lotes ou históricos de qualidade do armazém. Operações mostra a repartição recolhida em «Lotes usados nesta operação», usando os dados da data do registo. Correcções posteriores de identificação não reescrevem a transferência antiga e continuam a invalidar libertações desactualizadas nos locais. Repetição da transferência devolve a operação original antes de recalcular disponibilidade ou registar nova quarentena: não duplica movimentos e não volta a colocar em quarentena uma chegada já conferida. + +Limites restantes: transferência física imediata, com duas pontas atómicas; ainda não há expedição em trânsito e recepção física separadas. Movimentação de material em quarentena/vencido, selecção manual de lote, quarentena parcial de apenas uma fracção da mesma identidade, contagem/ajuste por lote, devoluções, abates e estornos continuam por desenvolver. Mantêm-se as restantes lacunas de revisão de parâmetros, reutilização explícita de identidade entre recepções, qualificação futura, relatórios/alertas/paginação e matriz Word anterior. A implementação não declara a proposta integralmente concluída nem altera regras financeiras. Próxima acção: contagens e ajustes repartidos por lote, preservando a diferença física e a conferência de qualidade sem transformar ajustes em consumo. + +**Verificação final das transferências — 01/11/2026:** suíte completa com 808 testes, 806 aprovados, dois ignorados anteriores e 8230 asserções. PostgreSQL descartável: 56 testes/417 asserções e sete corridas com contenção real; a nova corrida disputa duas transferências de 7 unidades sobre 10 e confirma uma única operação aceite, origem com 3, destino com 7 e total físico/repartido de 10. Inclui FEFO com múltiplos lotes, mesma identidade/metadados nas duas pontas, independência de autor e responsável, nova chegada sobre saldo já libertado, preservação de intervenientes após hold, retry depois de libertação, requisitos diferentes de validade no destino, ausência de controlo numa das pontas e imutabilidade dos dados usados no documento. TypeScript/build/lint/PHPStan dos serviços alterados aprovados; nove páginas locais HTTP/Inertia disponíveis. Sem nova migração, redefinição de contas, concessão de funções ou comandos de negócio na avaliação persistente; sem publicação/conferência visual. Provas: proofs/inventory-lot-transfers.json, proofs/supply-coordination-pg.json e proofs/supply-navigation.json. A avaliação local usa o código e os recursos compilados actualizados. + +## Contexto de trabalho e composição comercial — decisões do proprietário, 01/11/2026 + +Estado: análise e decisões registadas; selector, isolamento por opção empresarial e integração de RH ainda não implementados neste incremento. + +- Reutilizar OrganizationAccess, User::hasFunctions(), OrganizationConfiguration, auditoria e navegação central; a selecção nunca concede permissões. Configuração comercial por empresa limita módulos/opções ao produto adquirido, cumulativamente com a autorização funcional. +- Escolher área no Painel Inicial; com duas opções configuradas e autorizadas, o utilizador escolhe; com exactamente uma, activar automaticamente; com nenhuma, apresentar «Sem contexto autorizado», mantendo conta e parametrização apenas quando autorizadas. +- Administradores da empresa criam e atribuem perfis por área. Preservar perfis de desenvolvimento para coerência de desenvolvimento e actualizações; esta preservação não os transforma em permissões automáticas de clientes. +- Mudança com alterações por guardar oferece guardar, descartar ou cancelar. Falha de gravação mantém contexto actual. Processos submetidos conservam contexto de origem. +- Venda para terceiros significa comercialização de bens/serviços, incluindo retalho e grosso; referências a revenda no abastecimento não representam um percurso completo de vendas. +- No SGF, contratações de pessoas/prestadores são apenas internas; contratações validadas devem reflectir-se em recursos humanos. O marco exacto de validação e o contrato de integração de RH ainda precisam de ser definidos; selecção aprovada e contrato em rascunho são estados diferentes no percurso actual. +- Proprietário autorizou classificar os dados actuais de desenvolvimento como internos. Ainda não aplicada qualquer classificação/migração nesta etapa. +- Entrega a cada cliente deve usar instalação limpa, com os módulos/opções adquiridos parametrizados. Dados de testes e avaliação ficam localmente; não eliminar a base de desenvolvimento nem copiar dados de testes para clientes. «Recebe a base de dados completa» deve ser concretizado como estrutura/capacidades aplicáveis, respeitando esta decisão explícita sobre os dados. +- Falta distinguir expressamente se a exclusão de contratações para terceiros se aplica apenas à composição SGF ou também ao SGC autónomo: a instrução inicial previa ambas no SGC, enquanto a resposta posterior nomeia especificamente o SGF. Não retirar essa opção globalmente por inferência. + +Riscos identificados: permissões actualmente agregadas por empresa/utilizador; ausência de modalidade explícita nos modelos Requisition e PeopleProcess examinados; menus isoladamente não protegem consultas, URLs, relatórios ou comandos. Existem alterações locais anteriores em serviços, rotas e navegação que devem ser preservadas. Não duplicar motores, inferir direitos pelo contexto nem interpretar cadastro de pessoas como integração de RH concluída. + +Próxima acção: esclarecer alcance SGC/SGF e marco/destino de RH; implementar configuração comercial e contexto comum com validação no servidor e interface, vinculação dos processos e regressões em base isolada. Não foram executados testes funcionais porque esta etapa só registou análise/decisões; provas anteriores mantidas. + +## Contagens, ajustes, devoluções, regras de lotes e estornos — continuação de 01/11/2026 + +### Contagens e ajustes por lote + +InventoryCounts reutiliza a identidade e a versão da identificação de cada lote. Armazém introduz apenas quantidades observadas; a interface calcula o total exacto em milésimos, sem copiar o saldo teórico para campos de contagem. Lotes com saldo precisam de quantidade observada, incluindo zero quando não foram encontrados. Identidades esgotadas só entram na contagem se forem seleccionadas explicitamente; não são apresentadas automaticamente como novas observações de zero. Uma identidade existente pode ser reutilizada quando a quantidade reaparece; um lote realmente desconhecido recebe identidade própria após o ajuste, com origem na contagem, sem inventar recepção de fornecedor. + +A contagem conserva saldo/versão física, assinatura da repartição e identificação utilizada, sem movimentos. Outra pessoa, diferente de autor e responsável da contagem, com inventory_adjust_review, confere e aplica exactamente a diferença. Esse perfil não recebe saídas, transferências ou recepções por arrasto; um perfil apenas de Armazém não recebe a nova competência automaticamente. A mesma contagem só pode originar um ajuste. Alterações físicas, identificação ou âmbito activo invalidam a base; simples decisão de qualidade sem mudança de identidade/quantidade não é falsamente tratada como nova contagem. + +Faltas podem retirar quantidades de lotes em quarentena ou sem validade suficiente, sem representar consumo. Excedentes colocam todo o lote afectado no local em quarentena, incluindo saldo anterior, com independência dos intervenientes da contagem e do ajuste. Lotes sem divergência conservam a decisão anterior. Diferenças simultâneas entre lotes são corrigíveis mesmo com total inalterado: um movimento adjustment_out para as faltas e um adjustment_in para os excedentes, ambos na mesma operação/transacção, sem saldo paralelo. A migração 44 altera apenas a unicidade do razão para operação/local/tipo, preservando a unicidade de cada componente; razão e repartições ficam reconciliados. A contagem original, aprovação e metadados usados permanecem imutáveis. Periodicidade de inventário e tolerâncias organizacionais ainda não foram generalizadas em configuração própria. + +Migração 44 instalada exclusivamente na avaliação 8005 com backup/restauro verificados e 106 tabelas anteriores preservadas, sem novas tabelas, contas, permissões ou dados empresariais. Prova: proofs/inventory-count-install.json. O instalador aceita --counts separadamente de --lots, compara os registos existentes e não reaplica uma migração já registada. + +### Devoluções ao fornecedor e abates físicos + +Menu Armazém > Devoluções e correcções, com capacidades próprias inventory_disposition_read/prepare/review; execução física continua com Armazém. InventoryDispositions usa supply_records/42 para preparar, conferir, devolver para actualização e comprovar a saída. A aprovação exige independência de todos os preparadores; a execução exige pessoa diferente de quem aprovou. Uma referência de documento de stock não pode ser repetida. Estado/versão, chaves de repetição e auditoria fazem parte da mesma transacção da operação física. + +Devolução selecciona recepção física aceite e reutiliza fornecedor, ordem, linha, artigo e unidade. Quando existe controlo por lote, exige identidade exacta com origem comprovada nessa linha; igualdade de código e saldo inicial não provam origem de fornecedor. Transferências anteriores conservam a identidade e permitem seleccionar o local actual. Sem lotes, o percurso usa o local original e exige conferência explícita, sem alegar rastreio individual de unidades antigas. Quantidade física e total já devolvido são novamente conferidos na aprovação e execução; o acumulado líquido não excede a aceitação original, mesmo havendo saldo adicional de outras recepções. Lotes retidos ou sem validade podem sair por devolução ou abate, sem serem registados como consumo. Abate selecciona artigo/local/lote e fundamento próprios; não cria devolução ou crédito de fornecedor. Cada saída gera apenas o movimento negativo habitual e a repartição correspondente. + +Saída física e regularização financeira são distintas. Os percursos não criam crédito, restituição, despesa, pagamento ou redução de obrigação. Facturas existentes conservam os dados registados. Novas conferências de facturas descontam devoluções efectivas não estornadas; o ecrã de Finanças mostra os factos de devolução e correcção para confronto documental, sem assumir regularização concluída. Notas de crédito, restituições, contabilização do abate e integração correspondente com o núcleo financeiro comum continuam pendentes: não criar motor financeiro paralelo nem apresentar uma saída física como prova de crédito. Logística de devolução de bens rejeitados que nunca entraram em stock, substituição/reentrega autorizada e circuito aduaneiro de retorno continuam por completar. + +### Revisão de parâmetros e dispensa de quarentena previamente aprovada + +Configurações activas de lote já podem originar revisão pela interface, conservando referência/versão e os parâmetros existentes. A versão activa continua em uso enquanto outra aguarda conferência; a lista conserva o controlo activo e a proposta, sem esconder os lotes. A revisão não volta a repartir saldo nem cria lotes/movimentos. Mostra antes da decisão a quantidade utilizável actual e a prevista após alteração de exigência/dias mínimos de validade. Outra pessoa activa apenas se o saldo, identificação, qualidade e disponibilidade ainda corresponderem ao impacto preservado. Repetição é idempotente; uma segunda configuração activa independente não é criada por arrasto. + +A exigência de quarentena de recepções é parametrizável e começa activa. A dispensa só se aplica após conferência independente da configuração, a novas recepções tecnicamente conformes, completamente identificadas e com validade suficiente. Reservas de qualidade, identificação/validade desconhecidas e prazo insuficiente não beneficiam. Saldo inicial, transferências, excedentes de contagem e quantidades repostas por estorno continuam em quarentena. A identidade da recepção preserva a configuração aplicada; libertação por regra conserva release_mode=receipt_policy e a versão aprovada, visível como dispensa por regra, sem simular conferência manual do lote. Alterar a regra de quarentena afecta novas recepções; não reescreve decisões anteriores. Exigência/dias mínimos de validade passam a determinar disponibilidade corrente com impacto conferido. Desactivação do controlo, vigência futura programada e selecção manual de lotes em saídas/transferências ainda não implementadas. + +### Estornos integrais ligados à origem + +Estornos ficam no mesmo grupo de Devoluções e correcções, com três capacidades próprias inventory_reversal_read/prepare/review e execução por Armazém, independente da aprovação. InventoryReversals prepara apenas por referência à operação física ou linha de recepção original: artigo, locais, identidades, unidades e quantidades são reutilizados; não há redigitação de efeitos. Cada origem só pode ser estornada integralmente uma vez. Contagens sem efeito físico, aceitação de serviços, estornos parciais e estornos de outro estorno não pertencem a este percurso. Aprovação exige pessoa independente de todos os preparadores. Mudança da origem, identificação ou configuração exige actualizar a decisão. Retiradas precisam de saldo físico nos locais e lotes exactos; não compensam faltas com outra identidade/local. + +StockWorkflow mantém o único razão: novos movimentos reversal_out/reversal_in são inversos dos originais, atómicos entre locais e compatíveis com a migração 44. Repartições originais são reutilizadas e conservadas. Quantidades repostas em âmbito controlado colocam todo o lote local em quarentena, com independência dos intervenientes da correcção. Uma saída antiga, anterior ao controlo por lote, pode ser reposta como quantidade por identificar, com origem explícita no estorno, sem atribuir recepção de fornecedor fictícia. Uma entrada antiga sem repartição não pode ser retirada presumindo que corresponde a um lote inicial: falta reconciliação explícita dessa proveniência histórica. Referências a equipamentos/peças acompanham os efeitos e o histórico distingue a correcção. A quantidade grossa técnica da operação de estorno não é apresentada como consumo; os efeitos são mostrados individualmente por movimento/local. + +Correcções posteriores não apagam os originais: operação e recepção mostram a referência do estorno; processo preserva proposta, aprovação, execução, comprovativo, efeitos e lotes efectivos. Uma linha física de recepção integralmente anulada deixa de ocupar as quantidades efectivas recebidas/aceites da ordem, incluindo a parte documental rejeitada dessa linha, mas só a parte aceite tinha efeito físico a inverter. Documento original mantém as quantidades históricas e a repetição da confirmação original não recria stock. Uma nova recepção de correcção exige nova chave/documento; não há reutilização automática da guia original. Devoluções efectivas dessa origem precisam de reconciliação antes de anular a recepção inteira, evitando descontar devolução e anulação duas vezes. + +Saídas estornadas deixam de satisfazer reservas e de contar como consumo efectivo. As aplicações antigas continuam no diário; a necessidade pendente reaparece no calendário e pode associar uma nova saída efectiva, sem duplicação. A análise histórica incorpora a referência do estorno mesmo quando a correcção é posterior ao fecho do período: a assinatura muda e impede reutilização silenciosa de análise aprovada antiga. Consumo efectivo é corrigido; série física continua a representar os lançamentos nas respectivas datas, sem reescrever movimentos passados. Devolução ao fornecedor estornada deixa de descontar a quantidade líquida devolvida; novas conferências de facturas usam aceitação efectiva após devoluções e anulações. Facturas, orçamento, obrigações e pagamentos anteriores não são apagados nem estornados financeiramente por estes registos; a regularização documental permanece com Finanças/núcleo comum. + +### Interface e limites transversais + +As decisões de lotes, devoluções e estornos ficam recolhidas por registo, com uma acção escolhida e um único formulário de motivo/comprovativo, em vez de vários conjuntos repetidos de campos. Permissões da interface correspondem às do servidor; uma selecção de contexto ou origem não outorga funções. Estes incrementos continuam no roteiro de Compras/Armazém, sem criar vendas, confundir finalidade de reposição com percurso completo de comercialização nem alterar as decisões de composição SGC/SGF registadas noutra secção. Não foram aplicadas novas classificações comerciais aos registos existentes. + +Mantêm-se pendentes previsão avançada/backtesting/ABC/prazo observado, utilização operacional de alternativas/comparação elegível, rastreio e pesquisa completos, alertas/encaminhamentos completos, múltiplos embarques, retorno aduaneiro, notas de crédito/restituições e restante integração financeira comum, além das limitações concretas acima. Não declarar cobertura integral do Word ou prontidão comercial com base apenas no percurso físico. A verificação final conjunta e as provas desta continuação serão registadas abaixo; as provas anteriores e as decisões de contexto comercial foram preservadas. + +### Calendário e conferência final desta continuação + +O calendário reutiliza as projecções existentes e reúne atenções factuais no próprio ecrã: prazo de pedir, risco/ruptura, excesso por mínimo/embalagem, procura desconhecida e reservas/entregas/lotes que exigem conferência. Não cria mensagens externas nem encomendas. Acordo expirado/suspenso, versão alterada ou fornecedor/documentação inelegível retiram as datas de pedir/encomendar e o ponto de encomenda; o risco físico continua visível. Alteração do cadastro/unidade suspende a interpretação da política antiga até revisão, sem converter quantidades por suposição. + +Prova conjunta em docs/proofs/inventory-corrections.json: 98 testes PostgreSQL, 628 asserções e onze disputas reais de bloqueio passaram em base descartável; onze páginas HTTP/Inertia passaram em 8005, seleccionando o contexto de compras autorizado quando exigido. Nenhum comando de negócio, conta ou outorga foi criado pela navegação. TypeScript, compilação, análise PHP dos serviços/controladores alterados e integridade dos 18 ficheiros de execução passaram. A prova HTTP não é uma revisão visual por capturas. A bateria geral está a terminar e será registada a seguir. Alterações de contexto comercial presentes no espaço partilhado foram preservadas; não são atribuídas a este incremento de armazém. + +**Resultado final da bateria geral (01/11/2026):** 862 testes, 860 passaram, dois ignorados, 8527 asserções; sem falhas. Resultado inclui o código presente no espaço partilhado nesta execução. Prova consolidada: docs/proofs/inventory-corrections.json. Não substitui instalação em domínio, revisão visual ou regularização financeira. Próxima continuidade funcional: confrontar restantes lacunas da proposta com a integração de alternativas no processo de comparação e o núcleo financeiro comum; não criar motor financeiro paralelo nem declarar concluídos créditos/restituições, previsões avançadas ou retorno aduaneiro. + +## Relatórios de Pessoas e contratações — 01/11/2026 + +Corrigida ausência de entrada própria de relatórios no menu Pessoas e contratações. /pessoas/relatorios reutiliza PeopleProcess, PeopleHiring::kinds e PeopleAccess::visible, com isolamento por empresa e contexto, participação vigente por processo e capacidades existentes. Não concede consulta global aos administradores nem novas outorgas. Lista paginada (25), resumo por percurso/estado calculado sobre todos os resultados autorizados, filtros de percurso/estado/período de criação/título ou referência. Sem duplicação de cadastro. Impressão da página identificada como parcial, CSV de todos os resultados filtrados e ligação aos processos para relatório individual já existente. + +A listagem/exportação usa só referência, título, percurso, estado e datas: não carrega candidaturas, documentos, entrevistas, condições financeiras ou contratos. Resumo e página usam leitura consistente; resposta privada sem cache, histórico Inertia cifrado. CSV neutraliza títulos que poderiam iniciar fórmulas. Minuta preparada não é apresentada como contrato assinado/admissão. Exportação materializa os campos mínimos dentro da transacção para manter uma fotografia consistente; conjuntos excepcionalmente grandes poderão justificar exportação assíncrona autorizada futura. + +Verificação: 52 testes/1188 asserções de PeopleHiringTest e WorkContextTest passaram; inclui novos casos de visibilidade, expiração de acesso, filtros, empresa distinta e CSV. TypeScript, análise PHP do controlador, formatação e compilação passaram. HTTP local 8005 confirmou relatório e CSV no contexto people.internal, sem comandos de negócio, contas ou grants: docs/proofs/people-reports-http.json. Não feita revisão visual por capturas. Não exige migração nem alteração de dados existentes. + +**Correcção de navegação 409 em /pessoas/relatorios — 01/11/2026:** reproduzido ao seleccionar people.third_party, marcado não operacional, e abrir directamente o relatório. EnsureWorkContext redirecciona agora pedidos GET de modalidade ainda pendente ao Painel Inicial, onde a situação e opções autorizadas são apresentadas. Não muda a modalidade silenciosamente, não activa funcionalidades pendentes e mantém comandos de escrita bloqueados e isolamento por área/empresa. people.internal continua a abrir relatório/CSV normalmente. 53 testes/1217 asserções de Pessoas/contextos passaram; análise PHP e formatação passaram; scripts/people-reports-check.mjs verificou HTTP local do percurso pendente (302), relatório e CSV internos (200). Sem alteração de contas, grants ou dados de negócio. Em sessão com modalidade pendente, escolher Contratações internas no Painel Inicial para consultar os processos internos. + +**Visibilidade dos relatórios de Pessoas — 01/11/2026:** acrescentado botão Relatórios no topo de /pessoas, junto de Actualizar, e atalho Relatórios de pessoas e contratações no Painel Inicial quando a opção activa de Pessoas está operacional. Conservada entrada do menu e autorizações do servidor; não seleccionar contexto nem conceder acesso silenciosamente. Formatação/lint dos dois ecrãs, TypeScript e compilação passaram; relatório/CSV revalidados em HTTP local. Os atalhos tornam o acesso descobrível sem depender da abertura do grupo recolhível do menu. + +## Relatório de candidaturas e avaliações — 01/11/2026 + +/pessoas/relatorios?view=applications acrescenta contagens por processo em ambos os roteiros, com atalho visível no topo, filtros existentes, impressão paginada e CSV completo dos resultados filtrados. Reutiliza PeopleHiring::matrix para elegibilidade e mínimo de pareceres; selecção aprovada/minuta usa selection.rows preservado. Não recalcula retrospectivamente uma decisão por expiração posterior do acesso do avaliador. Registadas para avaliação conta people_applications uma vez, incluindo submissões admitidas por triagem; recebidas/admitidas/não admitidas/pendentes no portal são indicadores separados, sem somar portal e avaliação como pessoas distintas. Não se deduz exclusão a partir de falta de avaliação nem contratação a partir de admissão. + +PeopleApplicationReport conserva âmbito de PeopleAccess e contexto do relatório existente. Contagens de portal exigem CandidateTriage::canRead; devolvem null/Sem acesso quando não autorizadas, sem simular zero. Nenhuma identidade, documento, resposta, pontuação, motivo interno ou conteúdo contratual é devolvido nos indicadores. Avaliação completa, por concluir, sem parecer elegível e em curso são contagens; os conteúdos permanecem no percurso individual autorizado. Período filtra criação de processos, explicitamente, não datas de submissão. Ecrã calcula métricas apenas para processos da página; CSV percorre resultados filtrados completos numa fotografia de leitura consistente. Sem migração, cadastro duplicado, grants ou comandos de negócio. Não implementa nesta etapa relatórios de contratos assinados, admissão efectiva ou novos fluxos de selecção. + +**Verificação do relatório de candidaturas:** 76 testes/1683 asserções de PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Inclui admissão do portal sem duplicação, acesso reservado à triagem, selecção preservada, filtros e exportação. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local confirmou relatórios e CSV; sem revisão visual por capturas. Prova: docs/proofs/people-applications-report.json. + +## Relatório de selecções e contratações — 01/11/2026 + +Acrescentado /pessoas/relatorios?view=selections, por atalho visível e selector do relatório existente, para serviços individuais e recrutamento. PeopleSelectionReport reutiliza estado/selection/contract persistidos; mostra proposta por conferir/devolvida, selecção aprovada, datas da proposta/decisão e pendência correspondente. A minuta é sempre apresentada como preparada, sem inferir assinatura, formalização ou admissão efectiva a partir de starts_on. Não cria processos nem novas decisões e não faz integração RH. + +Âmbito continua a usar PeopleAccess::visible, permissões por percurso e contexto. Referência, preparação e início previsto da minuta exigem contract ou contract_view; caso contrário mostram Sem acesso. Não devolve termos, fundamentos, identidades de candidatos, pontuações nem conteúdos financeiros. Impressão segue a página; CSV inclui todos os resultados filtrados, com as mesmas restrições e neutralização de fórmulas também nas referências contratuais. Período refere criação dos processos; datas de proposta/decisão são campos factuais, não novos filtros. Resumo por estado mantém âmbito completo, detalhe apenas página actual; CSV mantém leitura consistente. + +Verificações: 78 testes/1839 asserções de PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Novos casos cobrem ambos os roteiros, devolução, reapresentação, aprovação, minuta, metadados contratuais reservados, CSV e expiração da participação. TypeScript, lint/formatação, compilação e análise PHP passaram. scripts/people-reports-check.mjs confirmou em HTTP local os três relatórios e respectivas exportações. Sem migração, grants, alteração de dados de negócio ou revisão visual por capturas. Próxima continuidade proposta: relatório de prazos e responsabilidades, reutilizando a mesa de trabalho existente; formalização/assinatura/admissão continuam fora deste incremento. + +## Relatório de prazos e responsabilidades — 01/11/2026 + +Acrescentado /pessoas/relatorios?view=deadlines, com atalho e selector na página de relatórios, impressão e CSV. PeopleDeadlineReport deriva exclusivamente de PeopleWorkdesk::build no âmbito autorizado e filtrado do utilizador. Não duplica regras de atribuição, independência ou tarefas, nem cria prazos, intervenientes ou notificações. Tarefas/conferências representam a intervenção autorizada do próprio utilizador; alertas do processo não atribuem culpa nem responsabilidade pessoal. Não é uma listagem de toda a equipa ou de toda a sua carga de trabalho. + +Atraso em dias de calendário usa a data da consulta no fuso da aplicação; prazo de hoje resulta zero, prazo desconhecido mantém null/Não determinável. Não interpreta ausência de prazo como tarefa em dia. O mesmo processo pode ter várias ocorrências (tarefas/alertas), explicitamente. Contadores/detalhe no ecrã referem processos da página; CSV percorre todos os processos filtrados. O resumo existente por estado continua a referir todos os processos filtrados. Datas de filtro são criação de processos, não vencimentos de tarefas. Conteúdos reservados de esclarecimentos, entrevistas, avaliações e contratos não são incluídos; referências e ligações conduzem ao percurso individual autorizado. Respostas privadas sem cache e leitura consistente existentes preservadas. Sem migração, dados de negócio/grants alterados ou integração RH. + +Verificações: 80 testes/1903 asserções em PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Cobrem intervenção própria/alerta, atraso, prazo ausente e expiração de acesso, além dos relatórios anteriores. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local verificou quatro vistas e quatro CSV, em scripts/people-reports-check.mjs, sem comandos de negócio; prova docs/proofs/people-deadlines-report.json e people-reports-http.json. Sem revisão visual por capturas. Relatórios da equipa completa, atribuição de novos prazos e formalização contratual não são prometidos por este incremento; ampliar exigiria percurso próprio e autorização funcional correspondente. + +## Prazos: filtros, totais completos e paginação por ocorrência — 01/11/2026 + +Relatório deadlines evoluiu com filtro opcional deadline_window: todos, vencidos, hoje, futuros e sem prazo. Datas comparadas no dia local da consulta; prazo de hoje não é atraso, ausência de prazo permanece desconhecida. Filtros de percurso, estado, pesquisa e criação dos processos continuam aplicados antes de obter a mesa de trabalho autorizada. O filtro de prazo actua sobre ocorrências derivadas, sem alterar tarefas nem criar datas. + +Reconcilia a limitação anterior: totais agora abrangem TODAS as ocorrências filtradas, e não só os processos da página. Apresenta número de processos considerados, processos com ocorrências e total destas, sem confundir ocorrências com pessoas/processos. Lista própria de 25 ocorrências por página (deadline_page), ordenada por maior atraso e data, CSV completo independente da página seleccionada; exportação inclui filtro de prazo e data de consulta. Impressão identifica página e totais. Resumo/lista genéricos de processos ficam fora desta vista para não misturar bases diferentes. Filtros foram colocados antes dos resultados nos quatro relatórios, facilitando a consulta sem atravessar listas longas. + +Verificação: 82 testes/2028 asserções de Pessoas, candidaturas e contextos passaram; inclui futuro/hoje/vencido, valores inválidos, desconhecido fora de atrasos, totais e duas páginas de 30 tarefas de um único processo, CSV integral mesmo ao pedir página 2. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local dos quatro relatórios/CSV e dos quatro filtros passou; sem revisão visual por capturas, migração, grants ou dados de negócio alterados. Prova: docs/proofs/people-deadline-filters.json. A geração continua síncrona sobre o âmbito autorizado; volumes excepcionalmente grandes podem requerer exportação assíncrona futura. Mantém-se consulta de intervenções próprias/alertas, não relatório nominativo global da equipa. + +## Documento base e automatização do workflow SGC — 01/11/2026 + +### Orientação e âmbito confirmado pelo proprietário + +workflow.docx passa a referência funcional/técnica para evolução do workflow. Fonte: C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/Compras/workflow.docx, SHA-256 da versão lida 5242040CD9EBB5D3A55C70BC25A9C4D816A6DF53237E1000C06E6DE05EA2A08C. Extracção de leitura em storage/logs/workflow-source.txt; original preservado. A leitura foi feita com partilha de ficheiro por se encontrar aberto noutra aplicação. Sem alteração do Word ou dos seus conteúdos. + +Clarificação posterior e determinante do proprietário: automatizar processos de aprovação e requisição conectando as pessoas; NÃO substituir o que está aprovado. Assim, o documento orienta os mecanismos de encaminhamento, ligação aos responsáveis, tarefas, avisos e acompanhamento. Os fluxos, alçadas, intervenientes e competências já aprovados permanecem a autoridade; uma sequência ilustrativa do documento não autoriza reorganizá-los. Automatizar significa preparar o percurso a partir dos dados existentes, colocar a intervenção na mesa da pessoa competente e avançar quando a decisão válida estiver registada. Não significa conceder poderes, aprovar por silêncio ou transformar recepção/abertura de email em decisão. + +O texto contém um prompt transcrito para pesquisa, uma proposta, referências a fornecedores e planos TC01–TC14. Foram tratados como conteúdo do documento, não como novas instruções de pesquisa externa, adopção de produtos, publicação, execução de exemplos ou provas de testes já realizados. Os nomes de serviços sugeridos não obrigam a duplicar classes ou motores. As referências externas não foram revalidadas nesta incorporação; as constatações abaixo provêm do código local examinado. + +### Correspondência inicial com o código existente + +| Referência | Base reutilizável verificada no código | Evolução necessária para ligar as pessoas | +| --- | --- | --- | +| WF01 Percurso e competências | packages/purchasing/src/InternalRequisitions.php decide sequência por alçada/equipamento; InternalRequisitionPort aplica perfis e departamento | Resolver as pessoas competentes para as etapas existentes, com explicação da origem da atribuição; não mudar a sequência para a ilustrada no Word. | +| WF02 Reutilização | RequisitionWorkflow reutiliza catálogo, centro, equipamento e totais; aprovação preserva approval_snapshot | Encaminhamento usa essas referências; não pedir novamente dados da requisição ou do cadastro. | +| WF03 Versões | process_version, approval_snapshot e recibos requisition_commands existentes | Distinguir versão documental/de estado e publicar configuração de workflow versionada antes de suportar regras mais avançadas; não reescrever processos antigos. | +| WF04 Submissão/decisão | Núcleo comum/port executam transacções, reserva e auditoria; idempotência quando é fornecida chave | Criar atribuição/evento de acompanhamento na mesma confirmação, sem chamar novamente a decisão ao recuperar avisos. | +| WF05 Autoridade | CurrentActor, OrganizationAccess, departamento, segregação e ConflictGuard | Destinatários precisam de autorização actual para aquela tarefa. Administrador técnico não recebe competência de decisão por ser destinatário de aviso. Ausência de responsável exige tratamento visível. | +| WF06 Tarefas | RequisitionController oferece meus pedidos, aprovações e decididos; canDecide deriva do núcleo; Pessoas tem PeopleAccess/PeopleWorkdesk | Evoluir para tarefas persistidas e ligações às pessoas existentes, preservando a autoridade do servidor e a separação entre roteiros. | +| WF07 Devolução/revisão | Núcleo de requisição examinado aceita approved/rejected; edição de draft; outros domínios têm revisões próprias | Devolução para correcção, retirada e nova submissão não estão demonstradas como ciclo completo da requisição neste núcleo. Qualquer adição deve preservar versões/decisões e tratar ordem emitida pelo percurso específico. | +| WF08 Paralelismo | Requisição actual é sequencial; Pessoas tem avaliações independentes e decisão própria | Não converter a sequência aprovada em paralelo automaticamente. Só acrescentar paralelismo onde a política aplicável o exigir, com tarefas distintas e conclusão única. | +| WF09 Delegação | Perfis, participações com prazo e funções existentes | Não equivalem a delegação aprovada com representado/representante. Falta percurso específico de âmbito, validade, autorização e revogação coordenada. | +| WF10 Prazos | Pessoas reutiliza prazos existentes, atraso factual e desconhecido sem inventar datas | Configurar calendários úteis, prazos por etapa e escalonamento antes de os calcular; não substituir dias de calendário já apresentados por dias úteis silenciosamente. | +| WF11 Avisos | Núcleos de decisão/auditoria existentes; não foi demonstrada caixa de saída transaccional completa do workflow | Implementar eventos persistidos, notificações internas por destinatário e recuperação; email depende de canal configurado. Falha de aviso não desfaz decisão nem a repete. | +| WF12 Recuperação de entrega | Proposta distingue PENDING, PROCESSING, ACCEPTED, UNKNOWN e restantes estados | Conservar evento/chave/tentativas, revalidar acesso e tratar incerteza antes de reenviar. Não prometer email único ou leitura sem comprovativo do canal. | +| WF13 Finanças e execução | BudgetWorkflow, PurchaseWorkflow, ReceiptWorkflow e InvoiceWorkflow próprios | Ligar estados de acompanhamento por referência, sem criar outro saldo, dívida, pagamento ou processo de compras. Aprovação de requisição não é autorização de pagamento. | +| WF14 Pessoas | PeopleHiring, PeopleAccess, tarefas e quatro relatórios já existentes | Reutilizar componentes comuns de ligação/tarefas, conservando candidatos, critérios, equipas e decisões no roteiro próprio. Não converter candidatura numa requisição. | +| WF15 Provas | Testes locais de requisição/núcleo comum e outras provas já guardadas | Mapear TC01–TC14 para implementações efectivas e executar disputas reais em PostgreSQL quando o mecanismo existir. Não marcar casos futuros como passados. | + +### Sequência de aplicação compatível com o aprovado + +1. Inventariar etapas e responsabilidades aprovadas e relacioná-las com perfis, funções, departamentos, centros e equipas actuais. Identificar responsáveis ausentes/ambíguos sem criar novas contas ou conceder poderes por omissão. +2. Acrescentar atribuições e mesa de tarefas sobre o núcleo existente, com versão de origem, prazo quando configurado, responsável e ligação directa à intervenção autorizada. Manter consultas sem efeitos de negócio. +3. Acrescentar avisos internos e eventos de saída duráveis; confirmar tarefa/decisão/estado/auditoria atomicamente. Não enviar mensagens externas nesta etapa sem configuração e autorização próprias. +4. Acrescentar lembretes, substituições/delegações e escalonamento conforme as regras aprovadas, depois de suportados os respectivos mecanismos de configuração e testes. Nenhuma ausência de resposta aprova automaticamente. +5. Completar devoluções/revisões e configuração versionada onde faltarem, sem substituir os percursos estáveis ou activar P1/P2 como se já existissem. + +Limites/alçadas, moedas, responsáveis nominativos, feriados, tempos de concessão/tentativas e canais são configuração da organização; exemplos do Word não os fixam. Persistência histórica, isolamento empresa/contexto, papéis distintos, núcleo independente de IA e implementação única dos motores mantêm-se. Instalações existentes conservam o percurso aplicado; qualquer migração de instâncias exige mecanismo explícito e auditável, sem arrastar dados de avaliação para clientes. + +### Verificação desta incorporação + +Leitura e confronto de RequisitionWorkflow, InternalRequisitionPort, núcleo InternalRequisitions, OrganizationConfiguration, PeopleWorkdesk e relatórios existentes. Quatro testes/34 asserções de InternalRequisitionCandidateTest passaram; 18 ficheiros dos núcleos distribuídos conferidos por SHA-256. Esta prova cobre a regressão dirigida existente, não implementa nem certifica todos os requisitos do Word/TC01–TC14. Nesta etapa só foi registada a base e a correspondência; não foram alterados estados, sequência de aprovação, base persistente, contas ou outorgas. Próxima implementação: ligar responsáveis e tarefas às etapas existentes, começando pelo diagnóstico de elegibilidade de encaminhamento. + +## Tarefas dos perfis e ligação às pessoas da etapa actual — 01/11/2026 + +Por orientação expressa do proprietário, a definição dos perfis deve indicar as tarefas exercidas para associação ao workflow. WorkflowTasks acrescenta catálogo de tarefas ligado às chaves de OrganizationAccess::PERMISSIONS, sem segunda fonte de concessão. Parametrização apresenta Tarefas e permissões do perfil, descrição das intervenções e resumo imediato das seleccionadas; guardar continua a usar o perfil/versionamento/auditoria/contextos existentes. Cada permissão mantém a abrangência anterior: as descrições não criam ainda selecção independente de cada subacção de uma capacidade abrangente. Todos os códigos de permissão existentes têm correspondência, com descrição específica para requisições e Pessoas e descrição funcional existente para os restantes. + +ApprovalRouting consulta a etapa pendente real da requisição e identifica pessoas da mesma empresa para as quais Requisition::canDecide devolve autorização, excluindo manutenção. Reutiliza função, departamento, independência do requisitante/aprovadores anteriores, conflitos e acessos/contexto correntes. Devolve apenas id/nome, função, etapa, chave funcional da tarefa e instante da consulta. O detalhe da requisição apresenta Quem pode tratar a etapa actual e avisa quando não existe pessoa elegível. Vários elegíveis constituem fila actual, não atribuição exclusiva; não escolhe arbitrariamente uma pessoa. Perfil administrativo não implica aprovação. Processo não pendente não mantém uma fila activa. Consultar não grava tarefas, decisões, eventos de aviso ou auditoria de negócio. + +Não muda sequência, etapas, alçadas, núcleo comum, reservas ou outorgas. O motor continua a revalidar ao decidir; esta consulta não é prova de autorização futura. Não foi criada nesta etapa atribuição persistida, tomada exclusiva, delegação ou envio de avisos. Próxima evolução: tarefas/avisos internos duráveis sobre esta ligação, preservando o executor efectivo e decisão do núcleo. Capacidades abrangentes ainda precisam de evolução coordenada no núcleo caso se pretenda seleccionar subacções separadamente; não simular separação só por texto/formulário. + +Verificações: 40 testes/403 asserções de InternalRequisitionCandidateTest, OrganizationConfigurationTest e WorkContextTest passaram. Novos casos cobrem elegibilidade, departamento, outra empresa, revogação de função, administrador sem poderes adicionais, ausência de alterações ao processo/auditoria e catálogo completo nas parametrizações. TypeScript, lint/formatação, compilação, análise PHP dos serviços e integridade dos 18 ficheiros de execução passaram. HTTP local conferiu catálogo e detalhe de requisição existente: proofs/approval-routing-http.json. Sem migração, alteração de dados persistentes/grants ou revisão visual por capturas. Prova consolidada: proofs/approval-routing.json. + +## Avisos internos ligados às etapas de aprovação — 01/11/2026 + +Migração 46 acrescenta workflow_notices com origem na requisição/etapa, destinatário, referência, função, criação e leitura; unicidade por etapa/destinatário. InternalRequisitionPort chama WorkflowNotices::capture após submissão/decisão, dentro da transacção do núcleo existente. Elegibilidade usa ApprovalRouting e canDecide, sem mudar funções, sequência, alçadas ou criar aprovações. A repetição idêntica recupera o recibo do núcleo sem recriar avisos. Se a persistência do aviso falhar, decisão/submissão, reserva, auditoria e recibo da operação são revertidos juntos. Sem migração instalada, instalações existentes mantêm o percurso e a página informa instalação pendente; não bloqueia decisões por ausência de um adicional ainda não instalado. + +GET /requisicoes/avisos mostra apenas avisos do destinatário na empresa/contexto actual, com etapa ainda pendente e autorização actual. Fila paginada, por ler e ligação ao processo. Etapa concluída, conflito, participação anterior incompatível ou acesso revogado retiram o aviso da lista activa sem apagar a origem. Marcar como lido é POST autenticado, com bloqueio da empresa, revalidação, auditoria e efeito idempotente; não decide, reserva, avança ou altera a requisição. Manutenção não recebe nem consulta fila operacional. Administrador não recebe avisos financeiros por ser administrador. Nenhum email, chamada externa ou confirmação de leitura de canal foi enviado/inferido. + +Este incremento é entrega interna na própria base, não executor externo/outbox de email. Não inclui garantias de entrega a canais externos, delegação, tomada exclusiva, agendamento, lembretes, reencaminhamento de instâncias antigas nem notificações de decisão ao requisitante. Perfis alterados depois da criação podem retirar acesso imediatamente; novos destinatários em etapas já abertas continuam a consultar Pendentes para mim, mas ainda não recebem aviso retroactivo. Dados e avisos anteriores conservados; não existe varredura de negócio em GET para os recriar. + +Instalação local 8005 efectuada por scripts/supply-install.php --notices, com identidade do destino conferida, backup privado e restauro em base descartável verificado; 106 tabelas existentes preservadas, nova tabela vazia e sem contas/grants/dados sintéticos. Proofs/workflow-notices-install.json. Domínio não instalado. Verificação dirigida passou 42 testes/455 asserções de requisição/configuração/contextos; análise PHP, formatação e 18 ficheiros dos núcleos conferidos. HTTP local confirmou página pronta. Verificação geral e PostgreSQL estão a ser concluídos e serão registados a seguir; não confundir com execução dos TC01–TC14 completos. + +**Resultado final dos avisos internos:** bateria geral 891 testes, 889 passaram, dois ignorados, 9236 asserções; sem falhas. PostgreSQL descartável: 21 testes/244 asserções de InternalRequisitionCandidateTest e WorkContextTest passaram, com base removida no finally. Tipos, compilação final, lint, análise PHP e HTTP local passaram. Prova consolidada: docs/proofs/workflow-notices.json. + +A tentativa de incluir OrganizationConfigurationTest inteiro no executor PostgreSQL revelou um teste existente de relatório que usa a transacção externa de RefreshDatabase, incompatível com a exigência já existente de transacção própria de leitura de ProcurementReviewReport. O erro foi diagnosticado explicitamente; não se alterou a guarda do relatório nem se marcou esse caso como passado em PostgreSQL. Organização está coberta pela bateria geral habitual; o executor PG deste incremento foi limitado aos dois ficheiros que verificam requisição/avisos/contexto, sem novos testes ignorados. Isto não demonstra execução da matriz concorrente TC01–TC14 completa. Próxima evolução: reencaminhamento controlado quando mudam perfis/destinatários de etapas já abertas e avisos de resultado ao requisitante, sem repetição da decisão. + +## Avisos de resultado ao requisitante — 01/11/2026 + +WorkflowNotices::outcome reutiliza workflow_notices da migração46 para avisar o requisitante quando a requisição termina approved/rejected. Destinatário vem da origem, função/decisão vêm da etapa efectivamente concluída e a unicidade etapa/destinatário já existente impede duplicação. O requisitante nunca integra a fila decisora da própria requisição, pelo que os avisos de resultado são distinguíveis das intervenções pendentes sem alterar os registos antigos ou acrescentar outro motor. Gravação ocorre no port da decisão, na mesma transacção do núcleo e antes da confirmação final; falha do aviso reverte decisão, auditoria, recibo e efeitos orçamentais. Repetir uma operação já confirmada recupera o recibo, sem recriar o aviso. + +Página /requisicoes/avisos passa a separar intervenções pendentes, resultados dos meus pedidos e por ler. Entrada de menu disponível também ao requisitante, sempre sujeita ao contexto e acesso no servidor. Resultados permanecem visíveis ao autor com acesso actual mesmo após terminar a intervenção de aprovação; avisos de tarefas terminadas continuam fora da fila. Cada resultado apresenta o facto aprovado/rejeitado e o estado actual separadamente, incluindo eventual cancelamento posterior, sem apresentar o facto histórico como estado vigente. Comentários, fundamentos internos, montantes e conteúdos de terceiros não são copiados para o aviso; ligação leva ao processo autorizado. + +Consulta/leitura revalida destinatário, empresa, contexto, permissões actuais e exclusão de manutenção; outro interveniente não pode ler ou marcar aviso do requisitante. Ler continua sem efeito de aprovação ou pagamento. Sem email, novo schema/migração, retroactividade de decisões antigas, grants, contas ou dados sintéticos. Reencaminhamento de perfis alterados, lembretes, delegação e envio externo continuam fora desta etapa. + +Verificações: 45 testes/573 asserções de requisição/configuração/contextos passaram; PostgreSQL descartável24 testes/362 asserções de requisição/contextos passaram. Casos novos cobrem approved/rejected, duplicação, leitura idempotente, outro destinatário, estado posterior distinto do resultado e reversão integral por falha de persistência. TypeScript, lint/formatação, compilação, análise PHP e integridade18 ficheiros de núcleo passaram. HTTP local confirmou as três vistas de avisos e dados de parametrização/encaminhamento sem criar decisões. Prova docs/proofs/workflow-outcomes.json. Sem revisão visual por capturas; bateria geral anterior permanece prova de base, não se atribuem os seus números a uma nova execução desta etapa. + +## Actualização controlada do encaminhamento — 01/11/2026 + +Acrescentada acção recolhida Actualizar encaminhamento no detalhe da requisição pendente, disponível a Administração/Compras com acesso efectivo e fora de manutenção. Exige motivo, etapa esperada, process_version e chave da operação. Não permite escolher destinatário livremente: WorkflowNotices::refresh reutiliza ApprovalRouting::current/canDecide e cria apenas avisos ainda inexistentes para os elegíveis actuais. Não altera o percurso, competências, etapa, orçamento, versão da requisição ou decisões. Não envia email e não cria delegação/atribuição exclusiva. + +Comando POST /requisicoes/{id}/encaminhamento bloqueia empresa, revalida o operador e bloqueia a requisição; guarda a auditoria workflow.routing_refreshed com motivo, etapa e destinatários antes/depois. A auditoria imutável conserva o recibo lógico (operation_key e assinatura) neste âmbito; repetição igual não volta a gravar auditoria ou avisos, conteúdo diferente com a mesma chave é recusado. Etapa/versão alteradas produzem erro de formulário legível, sem voltar a encaminhar outra etapa. Antes de recuperar um recibo, acesso actual à requisição e competência do operador voltam a ser conferidos. + +Avisos antigos e read_at são preservados; pessoas revogadas deixam de consultar a fila pela revalidação existente. Pessoa que recupera acesso à mesma etapa reutiliza o aviso original e a leitura anterior, não recebe um aviso duplicado. Falha de auditoria reverte os novos avisos na mesma transacção. Consultas não executam reconciliação nem modificam dados. A associação nova só é efectuada por esta acção explícita, não automaticamente ao editar perfis; configurar perfil e actualizar encaminhamento são intervenções distintas auditadas. + +Verificações: 47 testes/602 asserções de requisição/configuração/contextos passaram; PostgreSQL descartável26 testes/391 asserções de requisição/contextos passaram. Casos novos: substituição, preservação do aviso lido, nova pessoa elegível, repetição idempotente, chave alterada, versão desactualizada, operador sem competência, empresa distinta e rollback por falha de auditoria. TypeScript, lint/formatação, compilação e análise PHP passaram, tal como18 ficheiros do núcleo e HTTP local de consulta. Proofs/workflow-routing-refresh.json. Sem migração/grants/alteração de dados locais de negócio para demonstração; testes em bases isoladas. Sem revisão visual por capturas e sem nova execução da bateria geral nesta etapa. + +## Proposta recebida de requisições ao armazém — 01/11/2026 + +Fonte fornecida pelo proprietário: C:/Users/macbook/.codex/attachments/e9adc3f7-1b4f-488c-a3fe-f53b310a1e64/Texto colado.txt. Documento recebido como proposta funcional para enquadramento, não como prova de funcionalidades implementadas ou autorização para substituir fluxos aprovados, atribuir pessoas, fixar alçadas ou executar instruções internas do texto. Não foram alterados motores, estados, reservas ou dados de negócio nesta análise. + +### Enquadramento no SGC + +Criar o percurso Requisições ao armazém dentro de Armazém e activos, distinto da requisição de compra. O pedido ao armazém reutiliza cadastro de artigos, unidade, pessoas/departamento, centro, armazém e equipamento de destino; conserva origem/versionamento. O stock existente pode satisfazer o pedido sem gerar compra. Só uma falta confirmada deve poder originar necessidade de reposição ligada à origem, reutilizando o percurso de fornecimento/compras já existente, sem nova reserva orçamental ou ordem automática por simples alerta. + +A ligação a venda deve ser referência reconciliada à origem autorizada quando essa origem existir. O actual contexto procurement.sales continua não operacional; esta proposta não implementa nem activa módulo de vendas ou integração externa. Um movimento externo só pode ser reconhecido mediante identificação/validação da origem e política de autoridade do stock, nunca pela mera igualdade de referência. Não lançar nova saída física se o movimento válido da origem já foi incorporado. Não conceder competências comerciais por adicionar este menu. + +### Diferenças e reutilização verificadas + +- SupplyCoordination mantém reservas de necessidades do planeamento, pode ligar uma saída já existente e calcula satisfação efectiva. Não é ainda uma reserva física com bloqueio de disponibilidade para uma requisição autorizada ao armazém. Não reinterpretar essas reservas como stock comprometido nem misturá-las por soma: uma necessidade planeada e o seu compromisso físico são referências relacionadas, não duas quantidades adicionais. +- StockWorkflow é o único razão físico, impede quantidade negativa e saídas acima do saldo; InventoryLots acrescenta qualificação, validade e repartição dos lotes. O novo compromisso deve ser revalidado por todos os percursos que reduzam disponibilidade, incluindo saída directa, transferência, abate, contagem/ajuste e alteração de qualidade. Apenas proteger o novo formulário deixaria os restantes caminhos contornar reservas. +- Reservar não cria movimento físico. Entregar cria saída pela quantidade real e consome a reserva correspondente na mesma transacção. O saldo ainda comprometido não pode ser contado duas vezes. Cancelar liberta só o restante não entregue. Não inferir prazo de expiração; depende de configuração explícita. +- Usar o termo Saída por entrega neste percurso. InventoryDispositions já usa abate para inutilização/perda; não reutilizar esse comando para entrega normal. Material danificado, retido ou vencido não é disponível utilizável. +- InventoryCounts, InventoryLots e InventoryReversals já fornecem contagens/repartição/correcções físicas. Não bastam para declarar pronta a nova devolução do destinatário: a devolução deve ter recepção/qualidade e vínculo à saída de atendimento, sem reabrir o compromisso automaticamente. Preservar a semântica histórica de correcções de necessidades do planeamento, hoje distinta. +- Perfis/workflow devem explicitar tarefas de solicitar, decidir, conferir tecnicamente quando aplicável, preparar, entregar, confirmar recepção e regularizar; pessoas existentes recebem as funções pela parametrização, sem auto-aprovação nem contas/outorgas automáticas. Reutilizar tarefas/avisos comuns sem confundir o pedido de compra com o pedido ao armazém. + +### Requisitos de implementação e aceitação a conservar + +1. Pedido e linhas com referência única, origem, versões, destino e unidades do cadastro; percurso rascunho/submissão/decisão/autorização/preparação/parciais/entrega/encerramento e rejeição/cancelamento, condicionado à matriz que a organização definir. Não activar uma nova matriz com os papéis ilustrativos do documento como se já fossem outorgas reais. +2. Compromisso por linha/artigo/armazém, com quantidade autorizada, entregue, libertada e restante; disponibilidade utilizável menos compromissos activos, sempre na mesma unidade. Política explícita quando uma mudança de qualidade ou contagem reduzir o disponível abaixo do comprometido: identificar divergência/necessidades, sem libertar ou ajustar fisicamente por suposição. +3. Confirmar reserva e entrega sob disciplina comum de bloqueios, versões, idempotência e auditoria. Para vários artigos, confirmar o conjunto integral ou parcial expressamente autorizado; não produzir sucesso parcial acidental. Definir ordem global compatível com o bloqueio de empresa e motores existentes. +4. Entregas parciais dentro do autorizado, identificação de destinatário/entregador e recepção distinta; responsabilidades de uso/devolução quando aplicáveis. Alteração de itens/quantidades/destino após autorização exige nova avaliação, sem reescrever a decisão antiga. +5. Mínimos e alertas reutilizam parametrização artigo/armazém onde aplicável, com responsável e tratamento rastreável. Não geram compra, saída ou alçada automática. Os actuais mínimos/projecções não provam encaminhamento operacional concluído de uma ruptura. +6. Reconciliação de compromissos com fotografia consistente, detecção de órfãos/duplicações/expiração/entregas já consumidas, ocorrência e correcção independente, idempotente e auditada. Sem apagar histórico, libertar por antiguidade presumida ou converter diferença reservada em ajuste físico. +7. Conservar CA-01–CA-09, cenários de venda/interno/parcial/cancelamento/devolução/duplicação e reconciliação como plano de aceitação. Testes concorrentes em PostgreSQL descartável com sobreposição real; não declarar esses critérios passados com base nas provas de lotes/stock ou dos avisos anteriores. + +### Sequência proposta para o desenvolvimento + +Primeiro consolidar contrato de dados e responsabilidades com os cadastros/configurações existentes; depois pedido/autorização e compromisso físico; em seguida entrega parcial atómica no único StockWorkflow; por fim confirmação de recepção, devolução, reconciliação, alertas e integrações dependentes. Não disponibilizar compromisso físico enquanto os outros caminhos de saída puderem ignorá-lo. Séries, lotes, validade e integrações são requisito de arranque nos âmbitos que deles dependam, mesmo que classificados P2 no documento. Limites, calendários, expiração, alçadas e origem autoritativa de vendas continuam a ser configuração organizacional, não valores inventados pelo desenvolvimento. + +Esta incorporação é análise/documentação. Nenhum novo percurso foi activado, e não foram executados testes funcionais por não haver alterações de aplicação nesta etapa. Próxima acção concreta, se avançar esta proposta: especificar o vínculo entre necessidade planeada, pedido ao armazém e compromisso físico antes de alterar o motor de disponibilidade. + +## Disponibilidade para requisições ao armazém e regra de compra — 01/11/2026 + +/stock/disponibilidade acrescenta consulta por armazém e pesquisa de artigos, paginada, sobre o único StockWorkflow e InventoryLots. Mostra físico, utilizável pelos controlos activos, retido/indisponível, necessidades planeadas e mínimo aprovado. Os saldos são obtidos na mesma transacção de leitura consistente; quantidade/unidade não são convertidas. Sem controlo por lote essa limitação é visível; repartição divergente mantém utilizável/retido por determinar. Necessidades planeadas não são subtraídas como compromisso físico: este continua null/não implementado, nunca zero presumido. Mínimo só é reutilizado de política aprovada com cadastro preservado compatível; procura ou política desconhecida não se inventam. + +Acesso reutiliza as competências já autorizadas para consulta de stock e acrescenta capacidade warehouse_availability_read, configurável e sem outorga automática. Necessidades/mínimos de planeamento exigem essa capacidade ou leitura/reserva de fornecimento/admin; outros leitores de stock não recebem acesso a planeamento por arrasto. Empresa/contexto e armazém validam no servidor. A consulta não cria reserva, pedido, compra, movimento ou aviso. Menu Disponibilidade para requisições em Armazém e activos. Sem migração ou alteração dos processos existentes. + +O proprietário clarificou durante esta etapa: inexistência de stock ou atingir mínimo DEVE despoletar requisição de compra. Orientação incorporada em AGENTS.md. A geração automática ainda não está implementada nesta consulta, que o diz expressamente. Deverá partir de política/necessidade autorizada por artigo/armazém, determinar quantidade/reposição, requisitante e centro de custo a partir de configuração, evitar duplicação com reposição efectiva em curso e criar a requisição ligada à origem para seguir aprovações normais. Não converter uma alerta de leitura numa compra por si só; executar a geração por comando/transição/agendamento autorizado com idempotência e revalidação. Antes de gerar, conferir pedidos/ordens em curso e saldo utilizável; um pedido antigo cancelado ou insuficiente não pode suprimir reposição indefinidamente. Não inventar preços, quantidades, alçadas ou identidade do requisitante. + +Verificações desta consulta: 16 testes/123 asserções de WarehouseAvailabilityTest, SupplyCoordinationTest e InventoryLotPolicyTest passaram; inclui saldo físico10 versus necessidade planeada20 sem subtracção, mínimo atingido, leitura sem movimento, armazém de outra empresa e acesso reservado. TypeScript, lint/formatação, compilação e análise PHP passaram. Sem revisão visual por capturas. Esta prova não certifica geração automática, reserva física nem CA01–09 da proposta de requisições ao armazém. Próxima implementação: configuração e geração idempotente da requisição de compra a partir da necessidade, em conjunto com o contrato do pedido ao armazém e do compromisso físico. + +**Destino obrigatório da requisição ao armazém — clarificação do proprietário, 01/11/2026:** explicitar onde será usado. Uso interno identifica destino/unidade/local/finalidade; material para equipamento exige equipment_id proveniente do cadastro autorizado e compatibilidade quando aplicável; venda exige ligação à factura de venda e respectivas linhas/quantidades. Documentos e equipamentos devem ser seleccionados/referenciados, sem reintroduzir cadastro ou inferir identidade por nome. A linha deve conservar o destino aplicado, permitindo destinos distintos por linha se o pedido reunir usos diferentes; a interface só pede dados do destino aplicável. Eventual falta e requisição de compra resultante conservam o vínculo à necessidade/destino original, sem copiar a requisição inteira como se fosse nova necessidade independente. + +Facturas hoje tratadas por InvoiceWorkflow são de fornecedor: não as reutilizar como factura de venda por igualdade do termo. O actual percurso comercial/vendas continua não operacional; a referência de venda requer origem autorizada e reconciliação de artigos/quantidades/movimentos antes de disponibilizar esse cenário. Ligação a factura não pode duplicar saída já efectuada noutro sistema. Esta clarificação foi registada como requisito e não foi apresentada como campo/pedido ao armazém já implementado. A consulta de disponibilidade entregue nesta etapa continua sem criar pedidos ou movimentos. + +## Registo inicial de requisições ao armazém com destino — 01/11/2026 + +Implementado /stock/requisicoes com rascunhos próprios, listagem autorizada e consulta dos dados preservados. Nova tabela warehouse_requests (migração47) regista origem interna, armazém, requisitante/departamento, centro de custo, prioridade, local/finalidade e linhas com snapshot de artigo/unidade/quantidade. Número de apresentação RA-id único. Equipment destino seleccionado do cadastro do departamento quando use=equipment; peças cadastradas passam por EquipmentWorkflow::linkedPart. Em uso interno, as linhas herdam o local comum indicado; a associação a equipamento permanece explícita por linha. Quantidade positiva com três casas decimais, sem conversão de unidade e sem preços artificiais. + +WarehouseRequests::draft bloqueia empresa, revalida identidade/permissões, valida centros do departamento e fontes da empresa, guarda snapshot e auditoria atomicamente. Operação única por empresa/autor/chave; repetição idêntica recupera documento, conteúdo diferente é recusado. Mudanças futuras do catálogo não reescrevem o snapshot. Modelo não admite update/delete pela aplicação; edição/versionamento de rascunhos ainda não oferecidos nesta etapa. Registo não altera stock, reservas, orçamento, requisições de compra ou estado de aprovação. Ainda não existem comandos de submissão/autorização/entrega deste percurso; a interface esclarece esse limite. + +Tarefas configuráveis novas warehouse_request_prepare e warehouse_request_read, visíveis no catálogo de perfis. Preparador consulta os seus documentos; consulta da empresa exige read/admin, sem concessão automática de preparação ao administrador. Manutenção não cria rascunhos. Dados de artigos/centros/equipamentos para preparação só são enviados ao preparador autorizado. Contexto/empresa preservados, respostas sem cache e histórico Inertia cifrado. A selecção de origem de venda/factura de venda continua indisponível: o SGC actual não oferece fonte comercial autoritativa operacional e facturas de fornecedor não a substituem. Não aceitar uma referência inventada como ligação validada. + +Stock mínimo/inexistente continua requisito de geração de compra a implementar por configuração/origem autorizada, sem compras em GET e sem novos poderes. Reserva física, autorização, entrega, parciais, compra por falta, factura de venda, edição versionada e reconciliação não estão concluídos por este registo inicial; não apresentar rascunho como pedido autorizado ou compromisso de stock. + +Instalação em8005 via supply-install.php --warehouse-drafts, com backup/restauro verificados,107 tabelas existentes preservadas, tabela nova vazia, sem contas/outorgas/dados sintéticos. Prova proofs/warehouse-drafts-install.json; domínio não instalado. Testes:81/809 asserções (rascunhos, disponibilidade, equipamentos, requisições, configuração e contextos) passaram; PostgreSQL descartável6/66 passaram. Tipos, lint/formatação, compilação e análise PHP passaram. HTTP local confirmou página instalada e administrador sem preparação automática. Sem revisão visual por capturas; proofs/warehouse-drafts.json. Próxima acção: editar/versionar rascunhos e implementar submissão/autorização configurada, antes do compromisso físico e entrega. + +**Decisão confirmada do proprietário — documento de saída interno e venda no SGF (01/11/2026):** uso interno não tem factura. A requisição autorizada é o documento de saída do armazém; só a entrega física confirmada gera o movimento pela quantidade efectivamente entregue. Preparação/aprovação não abatem stock, e entrega parcial conserva saldo pendente. A opção de venda a terceiros será incorporada no SGF, que já dispõe de facturação, gestão de stock e contabilidade; nessa composição a requisição associa-se à factura de venda e reutiliza os mecanismos do SGF, sem duplicar movimentos ou motores. Esta decisão esclarece o âmbito comercial da proposta anterior e substitui qualquer interpretação de exigir factura no uso interno ou de criar facturação de vendas autónoma no SGC. Não remove por inferência compras/importações para revenda; trata do atendimento de vendas e documento de saída. Orientação incorporada em AGENTS.md; nesta confirmação não houve alteração de código, dados ou instalação e não foram necessários novos testes. + +## Autorização e entrega de requisições internas sem factura — 01/11/2026 + +Implementado percurso derivado de eventos: rascunho → submetida → autorizada → parcialmente entregue/entregue → encerrada com recepção confirmada; rejeição a partir da submissão. O documento original WarehouseRequest continua preservado; eventos imutáveis WarehouseRequestEvent guardam actor nominativo, versão, motivo, dados usados e data. Não se reescreve o rascunho nem se cria factura. Documento /stock/requisicoes/{id}, imprimível com destino/equipamento, quantidades pedidas/entregues/pendentes, decisões, lotes através da operação de stock referida, entregador, destinatário e confirmações. RA-id e cada evento de entrega referenciam as operações físicas correspondentes. + +Tarefas configuráveis novas warehouse_request_review (departamento) e warehouse_request_deliver (em conjunto com warehouse). Não houve outorgas automáticas. Requisitante submete e confirma recepção das entregas; não autoriza nem entrega o próprio pedido. A decisão exige responsável independente do mesmo departamento; quem autorizou não pode entregar. Leitura por tarefa/empresa/contexto é aplicada no servidor e nos menus; administração apenas lê se não tiver a tarefa operacional atribuída. Os avisos do workflow de requisição de compra não foram estendidos automaticamente a este novo domínio. + +O requisitante aceita ou não entregas parciais na submissão, e o autorizador só pode permiti-las quando foram aceites. Autorização integral confere saldo utilizável agregado por artigo/armazém; autorização parcial preserva a disponibilidade conferida e admite necessidade ainda por atender. Artigos/unidades/equipamentos usados são revalidados por comparação com a origem preservada; alterações incompatíveis exigem novo rascunho conferido. A configuração actual usa uma decisão departamental independente, sem inventar alçadas/limites monetários ou matriz técnica adicional; evolução desses critérios deve ser configurada e revista. + +Entrega regista apenas as quantidades efectivas positivas dentro do saldo autorizado de cada linha. Sem autorização para parcial, exige todas as quantidades pendentes. Usa StockWorkflow::record(kind=issue), o único razão existente, com validação de peças/equipamento e repartição/qualificação de lotes vigente. Não há novo motor de stock nem efeitos orçamentais/facturação. Disponibilidade é revalidada imediatamente na saída; falta/quarentena/validade ou erro numa linha revertem o conjunto completo. A transacção engloba operações físicas, evento do documento e auditoria; idempotência empresa/actor/chave e versão impedem repetição e entrega de uma página desactualizada. Confirmar recepção exige o requisitante e uma entrega concreta ainda não confirmada; não lança novo movimento físico. Entregas são registadas no instante real da operação, sem opção de retrodatação nesta etapa. + +Limites explícitos: autorização NÃO reserva fisicamente stock exclusivo; outros movimentos podem alterar disponibilidade até à entrega. Não se promete compromisso físico nem cobertura dos CA01–09 de reservas. Totais entregues são factos históricos de saídas registadas; devolução/correcção deve continuar ligada ao movimento original e não autoriza redespacho automático do saldo. Cancelamento/versão de pedido já submetido, expiração, destinatário alternativo, conciliação de reservas, tarefas/avisos deste novo percurso e geração automática de compra por falta/mínimo continuam pendentes. O requisito de compra permanece em AGENTS.md e a interface informa que ainda não é executado; não foi simulado por consulta/alerta. Venda/facturação mantêm composição SGF. A requisição interna é o documento de saída, sem factura. + +Instalação da migração48 apenas em8005 via --warehouse-events: backup e restauro verificados,108 tabelas existentes preservadas, nova tabela vazia e sem contas/grants/dados sintéticos. proofs/warehouse-fulfilment-install.json. Regressão dirigida passou85 testes/495 asserções; PostgreSQL descartável12 testes/122 asserções, mais duas disputas reais de bloqueio (mesma chave e chaves diferentes para a mesma entrega), passaram sem movimento duplicado. Tipos, lint/formatação, compilação, análise PHP,18 ficheiros dos núcleos e HTTP de consulta passaram. Bateria geral a concluir, resultado será acrescentado. Sem revisão visual por capturas; não declarar matriz integral da proposta concluída. + +**Verificação final da autorização/entrega:** bateria geral908 testes/906 passaram/2ignorados/9508 asserções, sem falhas. PostgreSQL12/122 e2disputas reais já descritos; prova consolidada docs/proofs/warehouse-fulfilment.json. O acesso operacional exige atribuição explícita das tarefas warehouse_request_prepare/review/deliver e, para entregar, warehouse. Sem atribuições automáticas. Não se confundem estes resultados com reserva exclusiva ou geração automática de compras, que continuam pendentes. diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md index 9a3f90e..8d791d8 100644 --- a/docs/orientacoes-continuidade.md +++ b/docs/orientacoes-continuidade.md @@ -1,5 +1,21 @@ # Continuidade do desenvolvimento do SGC +**Composição operacional/preparação de schema — 01/11/2026:** candidata `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`, sem instalação no SGF. `scripts/prepare-sgf-operational.php` prepara artefacto; `scripts/sgf-internal-install.php` distingue plan read-only e migrate transaccional de schema, com destino/manifesto/backup conferidos, contrato orçamental e journal imutável. Mesmo motor e permissões, novo ramo operacional por destino/schema/âmbito explicitamente habilitados, sem obrigar nome de base QA ou ambiente de ensaio. 20 controlos novos + restauro em production/base não-QA aprovados em [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json), sem repetir suites aceites. Não confundir preparação de schema com activação: não alterou código/provider/rotas/concessões/dados no SGF instalado; ponteiro 0.1.1 e candidatas anteriores preservados. Próximo passo é composição de arranque/rotas e prova HTTP numa cópia isolada. Não executar migrate no destino, activar 0.1.2/outra versão ou publicar sem a decisão separada. Reorganização modular mantém-se distinta e sem resultado próprio apresentado. + +**Preparação de acessos SGF — 01/11/2026:** criada sobreposição candidata de catálogo/gestão de permissões e atribuição inicial nominativa/auditada, com rotas/ecrã separados, sem alterações no host instalado. `scripts/prepare-sgf-access.php` produz hashes antes/depois e recusa divergências; não é instalador. 24 controlos novos PostgreSQL aprovados em [proofs/sgf-access-delivery.json](proofs/sgf-access-delivery.json), sem repetir suites aceites. Preservados os direitos anteriores na criação de unidades; nova autoridade inicial exige acção explícita de administrador autorizado e âmbito habilitado, nunca outorga automática. SGF mantém 0.1.1, candidata IR imutável e rotas não activadas. Próximo trabalho: composição orçamental operacional, instalador de schema/arranque, integração HTTP da preparação e pacote SGF específico; continuar sem instalar/publicar por arrasto. Reorganização modular não se confunde com este incremento e continua sem resultado próprio apresentado. + +**Classificação posterior à candidata — 01/11/2026:** equipamentos/peças são fora do âmbito do percurso básico entregue; catálogo administrativo/autoridade inicial de permissões, composição orçamental operacional e instalador/arranque rastreáveis são bloqueantes para instalar o percurso numa empresa. A guarda QA actual bloqueia inclusive consulta/configuração fora do ensaio. Não resolver com grants SQL, ambiente QA numa base real ou extracção do ZIP sobre a raiz SGF. Procedimento exacto do que existe, etapas sem executor e recuperação por falha registados em [transferencia-sgf.md](transferencia-sgf.md). Nenhum teste aprovado repetido, nenhuma mudança de código/candidata/destino. SGF mantém-se 0.1.1. A reorganização modular pedida depois é entrega distinta e permanece sem resultado apresentado; não a marcar concluída por esta integração. + +**Candidata de requisições internas — 01/11/2026:** implementado motor partilhado e adaptadores Compras/SGF para rascunho, submissão/reserva existente, aprovação por outra identidade e consulta/histórico. Contexto `procurement.internal`, isolamento por empresa/unidade, permissões renovadas, snapshots/versionamento, chaves de operação e locks no servidor. Configuração e escolha de contexto não atribuem direitos. Compras autónomo preservado; migração 46 ainda não aplicada à base persistente. Provas sintéticas isoladas incluem concorrência, revogação, falha transaccional, resposta perdida, migração falhada/compatibilidade e restauro de dados/schema com consulta posterior. Ver [matriz, limites e recuperação](transferencia-sgf.md), [artefactos](proofs/internal-requisitions-candidate.json) e [prova SGF](proofs/internal-requisitions-sgf.json). + +**Não activar:** SGF permanece 0.1.1. Não instalar 0.1.2, nova candidata, schema ou concessões no destino sem a decisão separada pedida pelo proprietário. Não publicar. Artefactos desta entrega são para revisão; updater actual recusa-os porque o seu contrato permanece só código. Rotas candidatas carregam apenas no bootstrap explícito de teste; sem alteração do menu público/instalado. Preparação operacional de migrações e catálogo privado administrativo pendente. Equipamentos/peças SGF e fornecedores continuam fora deste recorte; contratações para terceiros continuam excluídas do SGF. Não reabrir a via de navegador bloqueada nem declarar revisão visual feita. Os estados históricos seguintes conservam as respectivas provas e são sucedidos por este incremento. + +**Conferência antes de ampliar — 01/11/2026:** pedido posterior do proprietário restringe o próximo passo à confirmação de instalação independente, alcance do rollback e matriz transferido/parcial/pendente por processo/adaptador/ecrã. Matriz completa revista em [transferencia-sgf.md](transferencia-sgf.md), cópia reconciliada no SGF. Cliente não precisa de checkout/serviço/base de Compras; teste de origem sintética indisponível e prova real SGF com todos os includes dentro do host aprovados (mecanismo 6 testes/29 asserções, PHP SGF 8.4.27, sem base). Rollback é **só código/ponteiro**, não dados/schema; migrações futuras precisam de compatibilidade, autoria/versão, aplicação idempotente/auditada e backup/restauro/recuperação ensaiados. Prova `purchasing-client-independence.json`. SGF continua em `0.1.1`: extracção económica já em preparação foi interrompida antes de activar; candidata `0.1.2` existe no canal, **não instalada**. Preparação local passou 103 testes existentes e cinco de economia, sem constituir prova SGF. Não executar nova transferência por consequência desta revisão. Matriz propõe requisição interna até aprovação/consulta como próximo percurso completo, incluindo reserva existente, adaptadores e ecrãs, aguardando escolha de sequência na revisão. Contratações para terceiros continuam excluídas do SGF. Sem dados, concessões ou domínio alterados. + +**Prioridade expressa — transferência Compras → SGF e updates, 01/11/2026:** proprietário esclareceu que este é o foco, acima do selector de contexto. Primeiro pacote realmente transferido: projecção de reposição, fonte única `packages/purchasing`, fachada antiga e versão instalada no SGF sem dependência do checkout de Compras. Mecanismo `composer purchasing:transfer -- `: regressões, fotografia imutável, versão patch por mudanças, SHA-256/caminhos/inteireza, prova candidata no PHP do host, instalação imutável/ponteiro atómico/lock, histórico e rollback explícito de código. Versões `0.1.0 → 0.1.1`, rollback e reactivação reais locais aprovados; retry sem reinstalação. Final 86 testes/566 asserções no publisher, SGF Unit sem base 2/11 e análise PHP dirigida aprovados. Não executar reset da demonstração SGF; não houve migração/seed/conta/concessão ou dependência alterada. Contrato actual recusa pacotes com schema/permissões; canal deve ser de confiança (hash não é assinatura). Mecanismo actualiza apenas capacidades já extraídas: transferência integral de requisições, fornecedores, inventário operacional, aprovações/ecrãs e Pessoas internas ainda pendente. Conservar todas as funcionalidades SGC e excluir Pessoas para terceiros no SGF. Próximo trabalho é extracção/adaptação por percurso e migrações ensaiadas, não expandir novamente o selector. Ver [transferencia-sgf.md](transferencia-sgf.md) e provas `purchasing-*.json`. Sem publicação no domínio. + +**Retoma — contexto de trabalho, 01/11/2026:** primeiro incremento em código e 8005. Separados produto contratado/configuração empresarial/permissões; escolha em `/dashboard`, sessão vinculada a utilizador+empresa, auto-opção única ao escolher área, nenhuma opção sem dados de negócio, contexto visível, menus e middleware no servidor. Perfis associados explicitamente aos contextos, sem somar funções de outras opções. Migração 45: origem interna das requisições/processos de Pessoas (autorizada), scope e imutabilidade SQLite/PostgreSQL; backup/restauro e campos anteriores de 106 tabelas preservados. Avaliação local com catálogo completo só após conferência da base/role/empresa sintéticos; clientes usam limites comerciais por empresa + subconjunto activado pela interface. Novas empresas começam sem opções; compatibilidade de existentes exige configuração explícita antes de entrega. SGC mantém quatro opções; composição SGF exclui contratações para terceiros. Venda e contratações para terceiros ainda sem percurso operacional neste incremento, bloqueadas em vez de usar dados internos. Guardar/descartar/cancelar na ida ao painel: gravação integrada ao rascunho de requisição; outros formulários, múltiplas edições e histórico do navegador ainda exigem cobertura/conferência. RH só após formalização, nunca selecção/rascunho; envelope futuro sem envio, sem formalização legal ou módulo RH implementados. `sgc:delivery-check` confere sem apagar; clientes recebem base limpa, avaliação fica local. Catálogo comercial por módulo e editor dos limites adquiridos, distribuição SGF e actualizador integral pendentes. Provas contexto PG 12/83, dirigida final 66/644, regressão geral 858 aprovados/2 ignorados/8 503 asserções, análise PHP dirigida/build aprovados. PHPStan global com problemas web/candidate; visual indisponível porque browser host não respondeu. Sem publicação no domínio. Ver secção inicial de desenvolvimento para limites e próxima acção; não declarar pedido integralmente concluído. + **Retoma — triagem e avaliação, 30/10/2026:** decisão explícita de preparador atribuído em `/pessoas/{process}/recebidas/{submission}/triagem`, com ausência de conflito, motivo reservado e mensagem pública distintos. «Aceite para análise» cria candidatura interna a partir dos dados congelados, sem usar o perfil actual, associar por nome ou incluir automaticamente em base. «Não aceite para análise» não cria candidatura interna. Decisão/operação preservadas, retries idênticos idempotentes, revisão/reabertura ainda pendentes. Actor importador fica em `created_by`, mantendo independência na avaliação/aprovação; fonte electrónica e versão do perfil identificadas na matriz/relatório. Titular só recebe estado/data/mensagem pública, sem motivo interno ou ID interno. Preparadores e avaliadores expressamente autorizados a evidências/entrevistas consultam fontes; só preparador com função/atribuição actuais decide/publica. Painel oferece triagem antes de avaliar; fecho/avaliação/selecção verificam pendências. Não admitir novas candidaturas após começar a avaliação; não fundir referências manuais coincidentes. Migração 40 apenas 8005: backup/restauro e 103 tabelas antigas preservadas, tabela nova vazia, sem transferências automáticas, contas/grants, emails, IA ou domínio. Provas `proofs/candidate-triage*.json`. Próximo incremento: anexos verificados e complementos/revisões, depois modalidades espontâneas/fornecedores. Visual pendente; não declarar portal integralmente concluído. **Retoma — acesso e submissões electrónicas, 30/10/2026:** confirmação de email e recuperação implementadas, Mail::fake nos ensaios, transporte real indisponível e nenhum email enviado. Tokens SHA-256, segredo em fragmento/POST, expiração/utilização única, auditoria, invalidade após reenvio e revogação de sessões por versão na recuperação. Ler estado actual da conta ao autorizar, inclusive depois de locks, para não aceitar sessões antigas. Email confirmado não significa identidade validada. Sem token em logs ou flash. APP_URL configurado é a origem das ligações; não usar Host do pedido. Nova publicação explícita por preparador de Pessoas, formulário congelado do processo, candidatura de dados/comprovativo e consulta restrita pelo titular/preparador. Não divulgar propósito interno, critérios internos, selecção ou notas. Antes de consultar PeopleProcess no canal externo, retirar apenas o scope global de tenant e aplicar tenant explícito da rota/conta, além da publicação/prazo; nunca depender da sessão interna no mesmo browser. Não inserir candidato externo automaticamente em users/people_registry/people_applications. Recepção não é aceitação para análise, inclusão na base ou selecção. Formulários com anexos obrigatórios não podem publicar neste percurso enquanto não existir recepção/verificação de ficheiros; não retirar esses requisitos para contornar. Próximo: anexos verificados, triagem e ligação à avaliação sem redigitação, complementos/revisões e modalidades espontâneas/fornecedores. Migrações 38/39 locais 8005, backup/restauro, 100 tabelas anteriores preservadas, três novas vazias. Sem portal aberto, candidato criado, email real, IA, domínio ou aceitação visual. Provas `proofs/candidate-submissions*.json`. @@ -408,8 +424,110 @@ disponível e registar o resultado, sem interromper nem duplicar o executor. ## Consolidação de obrigações/pagamentos sintéticos — 30/10/2026 +Incremento posterior autorizado: reversão integral de afectação por proposta com motivo e confirmação independente, compensação imutável e nova afectação pelo núcleo comum. Prova sintética Compras/SGF: dívida 7 600 → 11 600 → 8 600 MZN e pagamento disponível 0 → 4 000 → 1 000. Núcleo único e históricos preservados; novo schema QA aplicado apenas nas bases descartáveis. As demonstrações persistentes não foram actualizadas; revisão local descartável disponível pelo runner documentado com --reversals --review. Sem direitos públicos, bancos, contabilidade, reversão parcial ou publicação. Visual/MFA pendentes e aceitação deste incremento não presumida. Ver [entrega de reversões](../../sgf-tempo/docs/SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md). + Retoma automática posterior: sequência aprovada em Compras e SGF, com asserções adicionais de repetição da confirmação, actores distintos e quatro eventos únicos preservados após reentrada. Só verificador/documentação alterados; bases descartáveis removidas. Sem alteração de permissões existentes ou habilitação pública. Visual pendente: último bloqueio é política de URL do navegador Windows, sem nova tentativa. Ver [relatório actualizado](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_CONSOLIDATION_20261030.md). Provas anteriores reutilizadas; sem checkpoint de encerramento. Apresentação comum corrigida sem motor novo: retorno à etapa após troca de função, saldo nas duas páginas e feedback. Prova HTTP/HTML descartável em Compras e SGF: dívida 11 600 antes da afectação, saldo 7 600 depois de afectar 4 000; repetição sem duplicação, reentrada e vencimento indeterminado. Núcleo comparado por SHA com a prova anterior e inalterado. Provas anteriores preservadas. Computer Use indisponível por pipe nativo ausente; visual pendente. Sem comandos financeiros nos cenários persistentes, migração habitual, grants, dependências, alteração da página inicial ou publicação. Preservados triagem/candidaturas e restantes percursos posteriores de Compras. Ver [relatório comum](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_DELIVERY.md), secção Consolidação do percurso; revisão antes da aceitação/encerramento. Aceite tecnicamente pelo responsavel em 30/10/2026, com checkpoint exclusivamente local autorizado. Revisao visual continua pendente, sem aceitacao visual presumida; MFA pendente conforme relatorio comum. Sem publicacao, habilitacao publica, credenciais ou dados privados. Alteracoes alheias excluidas do checkpoint. + +**Fornecimento/reposição — 01/11/2026, parcial:** proprietário pediu implementação do Word com reutilização de dados, preenchimento progressivo e funções claras para revenda/uso interno, ambos com compra local/importada. Código novo em SupplyPlanning/ReplenishmentProjection e /compras/fornecimento: acordos/políticas versionados, aprovação independente, três entradas no menu Compras e seis capacidades na parametrização. Importação ligada à ordem existente com marcos comprovados e bloqueio de recepção antes da libertação. Calendário apenas manual em dias corridos; motor A/B/C testado, mas reservas/entregas ainda não integradas no ecrã. Migração 42 preparada, não aplicada; nenhuma conta/atribuição alterada ou publicação. Documento integral NÃO concluído: matriz de cobertura e restantes incrementos no fim de desenvolvimento.md. Prosseguir com alternativas direccionais e reservas/entradas por linha, depois sugestões versionadas/conversão única e importação financeira pelo núcleo comum. Preservar alterações de candidaturas anteriores e não assumir nomes antigos do produto/menus como actuais. + +**Fornecimento — instalação posterior, 01/11/2026:** 000042 instalada apenas na avaliação 8005, backup/restauro comparado e 104 tabelas preservadas, tabela nova vazia. Corrigido alias de conexão na instalação; compras_dev conferida sem tabela/ledger novos. Provas supply-install.json e supply-navigation.json, três páginas HTTP/Inertia disponíveis. 96 testes dirigidos/652 asserções, tipos e PHPStan do âmbito aprovados. Política reutiliza prazo/origem e versão de acordo aprovado. Sem atribuições automáticas, dados de negócio ou publicação. Substitui apenas a indicação anterior de migração por aplicar; restante proposta continua pendente na matriz. Próxima execução deve continuar a implementação, sem interpretar este incremento como conclusão do Word. + +**Reservas/entregas/alternativas — 01/11/2026:** continuação entregue no código e na avaliação local sem nova migração. SupplyCoordination reutiliza supply_records/42; quatro capacidades próprias para reservas, confirmações, preparação técnica e validação técnica. Reservas em Armazém e activos, entregas e alternativas em Compras. Seleccionar fontes existentes (linha aprovada/catalogada, ordem emitida ou saída real); preenchimento da fonte pelo servidor, limites, versões e aprovação técnica independente de todos os preparadores. Recepção reutiliza artigo/local da confirmação. Calendário já inclui reservas pendentes, confirmações ainda por receber e saídas efectivas, sem duplicar consumo nas saídas antecipadas. Previsão habitual desconhecida mantém necessidades conhecidas com cobertura incompleta. Relações técnicas são direccionais e elegibilidade consulta validade/cadastro; utilização em compras e condições ainda por integrar. Confirmação única por linha, ainda sem múltiplos embarques. Provas PostgreSQL 14/101 e duas corridas com contenção real, seis páginas HTTP, tipos/build/lint/PHPStan do âmbito aprovados. Sem novas atribuições ou comandos de negócio na avaliação persistente; visual pendente. Matriz restante no fim de desenvolvimento.md: histórico validado, sugestões/conversão única, comparação elegível/substituições, lotes/devoluções/estornos, alertas e importação completa pelo núcleo financeiro comum. Estes pontos continuam autorizados; não pedir nova autorização técnica corrente nem declarar o Word integralmente concluído. + +**Regressão final de fornecimento:** 764 aprovados, dois ignorados anteriores e 7913 asserções; proofs/supply-coordination.json. Restante âmbito permanece na matriz, sem aceitação comercial integral presumida. + +**Sugestões e conversão — 01/11/2026:** menu Compras > Sugestões de reposição; três capacidades próprias (preparar, conferir, converter) na parametrização. SupplySuggestions reutiliza supply_records/42, catálogo e RequisitionWorkflow/StoreRequisitionRequest. Preserva cálculo e fontes, valida base actual antes de decidir/converter, autoria nominal, independência de todos os preparadores e pessoa que conferiu diferente do autor da requisição. Preço MZN do acordo e IVA configurado reutilizados; ausência de preço local exige estimativa fundamentada, sem câmbio inventado. Mínimo/embalagem e máximo no horizonte conferidos. Conversão cria apenas um rascunho, com centro de custo próprio, sem efeitos de compra/stock/orçamento/dívida/pagamento. Repetições idempotentes, vínculo e auditoria atómicos; proveniência mínima visível no detalhe da requisição autorizada. Evita plano automático concorrente enquanto o artigo/local estiver em acompanhamento; encerramento directo após rejeição ou recepção completa. Cancelamentos/revisões de origem e múltiplas necessidades independentes ainda não acompanhados automaticamente. Sem migração, novas concessões/contas ou publicação. A proposta integral continua em implementação; restantes pontos e limitações no fim de desenvolvimento.md. + +**Provas finais das sugestões:** 772 aprovados/dois ignorados anteriores e 8009 asserções; PostgreSQL 22/197 e quatro corridas com contenção real, incluindo conversão única e retry. Tipos/build/lint/PHPStan do âmbito e sete páginas HTTP locais aprovados. proofs/supply-suggestions.json conserva resumo e hashes das fontes. Visual/publicação pendentes; não reabrir este incremento como se as sugestões e a conversão directa ainda não existissem. + +**Histórico de consumo — 01/11/2026:** novo menu em Armazém e activos, capacidades próprias de preparação/conferência, diário append-only existente/42 sem nova migração. SupplyHistory calcula por artigo/local/período fechado a partir do razão, inclui dias sem consumo e exclui transferências/ajustes/recepções. Cobertura e finalidade/padrão confirmados por outra pessoa; ausência de dados não é consumo zero presumido. Política selecciona versão aprovada e define mínimo de dias/idade máxima; média exacta por numerador/denominador alimenta calendário e sugestões sem arredondar consumo pequeno a zero. Suspensão, alterações e antiguidade invalidam a reutilização sem reescrever o histórico, mantendo manual/necessidades conhecidas. Política pode originar novo rascunho reutilizando campos por referência, com nova conferência e uma única activa. Sem contas/grants ou dados de negócio na avaliação persistente. Média regular observada entregue; backtesting, métodos sazonais/intermitentes, ABC e prazo observado ainda pendentes. Restantes lacunas da proposta conservadas na matriz de desenvolvimento.md, sem presumir conclusão comercial integral. + +**Provas finais do histórico:** 786 aprovados/dois ignorados anteriores e 8122 asserções na suíte completa; 20/163 dirigidos após validação final. PostgreSQL 36/310 e quatro corridas de regressão com contenção real; oito páginas HTTP locais, tipos/build/lint/PHPStan do âmbito aprovados. proofs/supply-history.json conserva resultados e hashes. Visual/publicação pendentes. Não tratar média histórica regular e reutilização de políticas como funcionalidades ainda ausentes; métodos avançados e restantes lacunas mantêm-se na matriz. + +**Lotes/validade/quarentena — 01/11/2026:** primeiro percurso operacional em Armazém e activos > Lotes, validade e quarentena. Cinco competências próprias na parametrização; sem concessões automáticas. `InventoryLots` reutiliza o diário imutável/42 para preparação de âmbito, metadados e qualidade; migração 43 acrescenta apenas repartições de movimentos/saldo residual, mantendo `stock_movements` como única autoridade física. Activação por artigo/local exige conferência independente e saldo/versão actuais. Identificação única ou repartição exacta; sem identidade/validade confirmadas, material indisponível. Recepção permite registar lotes em secção opcional, reutiliza quantidade aceite quando lote único e conserva origens; entra em quarentena. Libertação independente de todos os autores da identificação/recepção, motivo/versões/idempotência; correcção invalida libertação anterior sem apagar decisões. Saídas automáticas por validade mais próxima, apenas utilizáveis, uma única saída física. Stock/operações mostram utilizável e indisponível sem conceder acesso adicional ao histórico. Calendário/sugestões reutilizam contexto e descontam apenas remanescente projectado que perde validade; entradas futuras sem qualificação não contam como utilizáveis, com aviso. Âmbitos sem lotes e retries de recepções antigas conservam compatibilidade. + +**Instalação/provas de lotes:** 43 instalada apenas na avaliação 8005, backup/restauro verificados, 105 tabelas preservadas e nova tabela vazia, sem contas/grants/activação/dados empresariais novos. 799 aprovados/dois ignorados anteriores e 8192 asserções na suíte completa; PostgreSQL 49/379 e seis corridas com contenção real (incluem dupla saída e identificação/libertação concorrentes). Nove páginas HTTP/Inertia, tipos/build/lint/PHPStan do âmbito aprovados; sem conferência visual ou publicação. proofs/inventory-lot-install.json, inventory-lots.json, supply-coordination-pg.json e supply-navigation.json. Não repetir a instalação 43 nem recriar tabelas: o instalador detecta ledger existente. + +**Próxima implementação autorizada:** completar transferências entre âmbitos com lotes, quarentena no destino, contagem/ajuste por lote e devoluções/abates/estornos com conservação exacta e independência. Neste momento transferências/ajustes com artigo controlado em qualquer ponta são impedidos pelo servidor, com motivo visível antes da activação; contagem permanece sem efeito físico. Não retirar esta guarda sem implementar repartição das duas pontas/ajuste. Falta revisão de parâmetros activos, política conferida de dispensa de quarentena e selecção manual de lote, reutilização explícita de identidade entre recepções, qualificação futura, relatórios/alertas e paginação completa (100 controlos/200 eventos actuais). Restantes lacunas Word mantêm-se na matriz de desenvolvimento.md; lotes iniciais/recepção/saída/qualidade não equivalem a cobertura integral da proposta. Continuar sem pedir nova autorização para trabalho técnico corrente já solicitado. + +**Transferências por lote — 01/11/2026:** StockWorkflow já reparte origem e destino da transferência normal, com os dois movimentos habituais e a mesma identidade de lote. Ambos os âmbitos têm de estar activos e reconciliados; saída automática por validade mais próxima, apenas libertados/utilizáveis. Substitui a indicação anterior de transferências totalmente pendentes; ajustes por lote continuam pendentes e protegidos. Nova chegada coloca todo o saldo da identidade no destino em quarentena, incluindo quantidades anteriores, com aviso nos ecrãs; origem conserva decisão local. Autor e responsável da transferência integram a independência pendente da custódia, além de autores da identificação; hold posterior não elimina intervenientes e nova libertação tem controlo de versão. Parâmetros de validade do destino são aplicados independentemente. Operação/movimentos congelam somente os lotes utilizados e as versões aplicadas; detalhe visível no histórico normal. Retry não duplica movimentos nem reabre quarentena depois de conferência. Sem nova migração/grants/dados empresariais na avaliação. Transferência ainda imediata, sem expedição em trânsito/recepção física separadas; não movimenta lotes indisponíveis. Seguir com contagem/ajuste por lote e depois devoluções/abates/estornos, mantendo conservação, qualidade e restantes lacunas da matriz. Não declarar o Word completo. + +**Provas finais das transferências:** 806 aprovados/dois ignorados anteriores e 8230 asserções; PostgreSQL 56/417 e sete corridas com contenção real, incluindo dupla transferência do mesmo lote com conservação das duas pontas. Tipos/build/lint/PHPStan dos serviços alterados e nove páginas HTTP/Inertia locais aprovados. proofs/inventory-lot-transfers.json conserva resultados e hashes. Avaliação 8005 actualizada pelo código/recursos compilados, sem nova migração, novas atribuições ou dados de negócio. Conferência visual/publicação não realizadas. A próxima lacuna operacional é contagem/ajuste por lote; não tratar transferências imediatas entre âmbitos activos como ainda ausentes. + +## Contexto de trabalho e composição comercial — decisões do proprietário, 01/11/2026 + +Estado: análise e decisões registadas; selector, isolamento por opção empresarial e integração de RH ainda não implementados neste incremento. + +- Reutilizar OrganizationAccess, User::hasFunctions(), OrganizationConfiguration, auditoria e navegação central; a selecção nunca concede permissões. Configuração comercial por empresa limita módulos/opções ao produto adquirido, cumulativamente com a autorização funcional. +- Escolher área no Painel Inicial; com duas opções configuradas e autorizadas, o utilizador escolhe; com exactamente uma, activar automaticamente; com nenhuma, apresentar «Sem contexto autorizado», mantendo conta e parametrização apenas quando autorizadas. +- Administradores da empresa criam e atribuem perfis por área. Preservar perfis de desenvolvimento para coerência de desenvolvimento e actualizações; esta preservação não os transforma em permissões automáticas de clientes. +- Mudança com alterações por guardar oferece guardar, descartar ou cancelar. Falha de gravação mantém contexto actual. Processos submetidos conservam contexto de origem. +- Venda para terceiros significa comercialização de bens/serviços, incluindo retalho e grosso; referências a revenda no abastecimento não representam um percurso completo de vendas. +- No SGF, contratações de pessoas/prestadores são apenas internas; contratações validadas devem reflectir-se em recursos humanos. O marco exacto de validação e o contrato de integração de RH ainda precisam de ser definidos; selecção aprovada e contrato em rascunho são estados diferentes no percurso actual. +- Proprietário autorizou classificar os dados actuais de desenvolvimento como internos. Ainda não aplicada qualquer classificação/migração nesta etapa. +- Entrega a cada cliente deve usar instalação limpa, com os módulos/opções adquiridos parametrizados. Dados de testes e avaliação ficam localmente; não eliminar a base de desenvolvimento nem copiar dados de testes para clientes. «Recebe a base de dados completa» deve ser concretizado como estrutura/capacidades aplicáveis, respeitando esta decisão explícita sobre os dados. +- Falta distinguir expressamente se a exclusão de contratações para terceiros se aplica apenas à composição SGF ou também ao SGC autónomo: a instrução inicial previa ambas no SGC, enquanto a resposta posterior nomeia especificamente o SGF. Não retirar essa opção globalmente por inferência. + +Riscos identificados: permissões actualmente agregadas por empresa/utilizador; ausência de modalidade explícita nos modelos Requisition e PeopleProcess examinados; menus isoladamente não protegem consultas, URLs, relatórios ou comandos. Existem alterações locais anteriores em serviços, rotas e navegação que devem ser preservadas. Não duplicar motores, inferir direitos pelo contexto nem interpretar cadastro de pessoas como integração de RH concluída. + +Próxima acção: esclarecer alcance SGC/SGF e marco/destino de RH; implementar configuração comercial e contexto comum com validação no servidor e interface, vinculação dos processos e regressões em base isolada. Não foram executados testes funcionais porque esta etapa só registou análise/decisões; provas anteriores mantidas. + +**Continuação de armazém — 01/11/2026, contagens/ajustes/devoluções/regras/estornos:** contagens físicas por lote reutilizam cadastro e calculam total; inventory_adjust_review confere/aplica por pessoa diferente de autor/responsável. Diferenças mistas, inclusive total inalterado, usam adjustment_out/in atómicos; excedentes ficam em quarentena. Migração 44 instalada só em 8005, backup/restauro verificados, 106 tabelas preservadas, sem novas tabelas/grants/dados de negócio; proofs/inventory-count-install.json. Devoluções e abates têm preparação/conferência próprias e execução por Armazém diferente do aprovador; reutilizam origem e mantêm limite físico/aceitação, incluindo lotes retidos/vencidos sem contar como consumo. Novas facturas descontam devoluções líquidas; documentos anteriores e núcleo financeiro permanecem preservados, com factos para conferência em Finanças. + +**Parâmetros de lotes já revistos pela interface:** versão activa continua durante proposta de revisão; aprovação independente revalida saldo/qualidade/identificação e impacto quantitativo. Não repartir saldo novamente. Dispensa de quarentena só após configuração aprovada e apenas para recepções futuras conformes/identificadas/válidas; versão aplicada e modo por regra ficam preservados/visíveis. Saldo inicial, transferências, excedentes e estornos positivos continuam retidos. Falta desactivação, vigência programada e selecção manual de lotes em saídas/transferências. + +**Estornos físicos já implementados:** capacidades inventory_reversal_read/prepare/review, execução por Armazém independente da decisão. Integral e único por operação física/linha de recepção, com dados reutilizados e original preservado. reversal_in/out e repartições inversas usam StockWorkflow, sem novo razão. Reposição controlada exige qualidade independente; saída antiga sem lote reposta fica por identificar. Não inferir correspondência de entrada antiga com lote inicial. Reservas reabrem quantidade efectiva e análises históricas corrigem consumo/assinatura mesmo com estorno posterior ao período; séries físicas mantêm datas de lançamento. Recepção anulada deixa de ocupar quantidades efectivas; retry da original não recria stock. Devolução estornada deixa de descontar devolvido líquido. Não confundir com crédito, restituição ou estorno financeiro: falta regularização no núcleo comum, sem motor paralelo. UI mostra decisões recolhidas, uma acção/formulário por registo. Mantêm-se restantes lacunas na matriz e decisões de composição comercial anteriores; não declarar Word integralmente concluído. Verificação final conjunta será acrescentada após os testes, sem substituir provas anteriores. + +**Calendário/conferência desta continuação:** atenções factuais no calendário existente; acordo/fornecedor desactualizado retira prazos derivados, cadastro/unidade alterado exige rever política. Prova conjunta em proofs/inventory-corrections.json: PostgreSQL 98 testes/628 asserções e onze disputas reais passaram; onze páginas locais passaram com contexto de compras autorizado, sem comandos de negócio/grants. Tipos, compilação, análise PHP e 18 ficheiros comuns conferidos. Sem revisão visual por capturas. Bateria geral ainda em execução; resultado será acrescentado sem substituir provas anteriores. + +**Resultado final da bateria geral (01/11/2026):** 862 testes, 860 passaram, dois ignorados, 8527 asserções; sem falhas. Resultado inclui o código presente no espaço partilhado nesta execução. Prova consolidada: docs/proofs/inventory-corrections.json. Não substitui instalação em domínio, revisão visual ou regularização financeira. Próxima continuidade funcional: confrontar restantes lacunas da proposta com a integração de alternativas no processo de comparação e o núcleo financeiro comum; não criar motor financeiro paralelo nem declarar concluídos créditos/restituições, previsões avançadas ou retorno aduaneiro. + +**Relatórios de Pessoas (01/11/2026):** menu próprio em /pessoas/relatorios, resumo por percurso/estado, filtros, paginação, impressão desta página e CSV completo dos resultados filtrados; ligação ao relatório individual existente. Reutiliza permissões por percurso e participação vigente no processo, empresa/contexto; admin não recebe acesso global. Sem dados privados de candidatos/entrevistas/contratos/finanças na listagem. 52 testes/1188 asserções, tipos, análise PHP, compilação e duas verificações HTTP locais passaram; prova proofs/people-reports-http.json. Sem migração/grants. CSV materializa campos mínimos para fotografia consistente; considerar exportação assíncrona para conjuntos muito grandes. Sem revisão visual por capturas. + +**Correcção de navegação 409 em /pessoas/relatorios — 01/11/2026:** reproduzido ao seleccionar people.third_party, marcado não operacional, e abrir directamente o relatório. EnsureWorkContext redirecciona agora pedidos GET de modalidade ainda pendente ao Painel Inicial, onde a situação e opções autorizadas são apresentadas. Não muda a modalidade silenciosamente, não activa funcionalidades pendentes e mantém comandos de escrita bloqueados e isolamento por área/empresa. people.internal continua a abrir relatório/CSV normalmente. 53 testes/1217 asserções de Pessoas/contextos passaram; análise PHP e formatação passaram; scripts/people-reports-check.mjs verificou HTTP local do percurso pendente (302), relatório e CSV internos (200). Sem alteração de contas, grants ou dados de negócio. Em sessão com modalidade pendente, escolher Contratações internas no Painel Inicial para consultar os processos internos. + +**Visibilidade dos relatórios de Pessoas — 01/11/2026:** acrescentado botão Relatórios no topo de /pessoas, junto de Actualizar, e atalho Relatórios de pessoas e contratações no Painel Inicial quando a opção activa de Pessoas está operacional. Conservada entrada do menu e autorizações do servidor; não seleccionar contexto nem conceder acesso silenciosamente. Formatação/lint dos dois ecrãs, TypeScript e compilação passaram; relatório/CSV revalidados em HTTP local. Os atalhos tornam o acesso descobrível sem depender da abertura do grupo recolhível do menu. + +**Relatório candidaturas/avaliações (01/11/2026):** disponível por atalho em /pessoas/relatorios?view=applications; contagens por processo, filtros/CSV/impressão existentes. Reutiliza matrix e, após aprovação, selection.rows preservado; portal separado de candidaturas registadas para evitar duplicação. Triagem só com canRead, sem acesso representado por null. Sem dados pessoais/evidências nos indicadores e sem grants/migração. Período refere criação do processo. Restam relatórios de selecções/contratações e prazos/responsabilidades propostos; não inferir contrato assinado a partir de minuta. + +**Verificação do relatório de candidaturas:** 76 testes/1683 asserções de PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Inclui admissão do portal sem duplicação, acesso reservado à triagem, selecção preservada, filtros e exportação. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local confirmou relatórios e CSV; sem revisão visual por capturas. Prova: docs/proofs/people-applications-report.json. + +**Selecções e contratações (01/11/2026):** relatório em /pessoas/relatorios?view=selections, com proposta/devolução/aprovação, datas, pendência e metadados da minuta apenas com contract/contract_view. Não inclui termos/fundamentos/candidatos/finanças e não infere assinatura/admissão. Filtros, impressão, CSV e isolamento existentes reutilizados; CSV neutraliza referência contratual. 78 testes/1839 asserções e tipos/lint/build/análise PHP passaram; HTTP dos três relatórios/CSV passou. Sem migração/grants/dados alterados. Próxima acção: relatório de prazos e responsabilidades a partir da mesa de trabalho. Formalização e integração RH permanecem fora deste incremento. + +**Prazos/responsabilidades (01/11/2026):** /pessoas/relatorios?view=deadlines reutiliza PeopleWorkdesk, com tarefas/conferências próprias e alertas dos processos distinguidos. Não atribui alertas a pessoa, não inventa prazos; dias de calendário, prazo ausente null. Ecrã por página; CSV completo filtrado, sem conteúdos reservados. 80 testes/1903 asserções, tipos/lint/build/análise PHP e oito verificações HTTP passaram. Sem migração/grants/dados alterados; proofs/people-deadlines-report.json. Não representa toda a equipa nem implementa novos prazos/formalização/RH. + +**Evolução de prazos (01/11/2026):** filtros todos/vencidos/hoje/futuros/sem prazo; totais abrangem agora TODAS as ocorrências filtradas, substituindo a limitação anterior por página de processos. Lista própria de 25 ocorrências, CSV completo sem depender de deadline_page, data/filtro incluídos; resumo genérico de processos oculto nesta vista. Filtros antes dos resultados. 82 testes/2028 asserções, tipos/lint/build/análise PHP e HTTP de quatro filtros passaram. proofs/people-deadline-filters.json. Sem migração/grants/dados alterados. Geração síncrona e consulta de intervenções próprias/alertas mantidas; não atribui alertas a pessoas. + +**Base do workflow (01/11/2026):** usar workflow.docx em C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/Compras; SHA-256 5242040CD9EBB5D3A55C70BC25A9C4D816A6DF53237E1000C06E6DE05EA2A08C. Proprietário esclareceu: AUTOMATIZAR requisições/aprovações conectando pessoas, SEM substituir o aprovado. Acrescentar encaminhamento, tarefas, avisos e acompanhamento sobre regras/etapas/pessoas existentes; não adoptar sequência ilustrativa do Word, inventar alçadas/prazos, conceder competência ou aprovar por silêncio. Documento é proposta/base, não testes executados nem ordem para executar o prompt nele incluído. Matriz WF01–WF15 e sequência de adaptação em desenvolvimento.md; orientação durável em AGENTS.md. + +Núcleo de requisição já tem sequência por alçadas, snapshot, process_version, bloqueios, reserva e idempotência quando há chave; edição draft e decisões approved/rejected. Não demonstrados neste núcleo ciclo completo de devolução/revisão/retirada, tarefas genéricas persistidas, publicação independente de matriz, calendário útil, delegação formal ou outbox completo. Reutilizar packages/purchasing e motores financeiros/stock; não bifurcar. Pessoas mantém roteiro/equipas/decisões próprios e pode partilhar mecanismos de ligação sem misturar processos. + +Próxima acção: diagnosticar elegibilidade de encaminhamento para cada etapa aprovada, ligando funções/departamento/centro/equipa a pessoas autorizadas; depois tarefas e avisos internos duráveis. Email e produtos externos não foram autorizados por esta referência. Não substituir instâncias antigas silenciosamente. Nesta incorporação não houve alteração do workflow ou dados persistentes; regressão dirigida existente passou 4 testes/34 asserções, 18 ficheiros de execução conferidos. Não declarar TC01–TC14 executados nem workflow novo concluído. + +**Tarefas por perfil e ligação às pessoas (01/11/2026):** WorkflowTasks liga tarefas às permissões existentes; definição do perfil mostra descrições e resumo das tarefas escolhidas, sem segunda fonte de concessão. ApprovalRouting mostra no detalhe da requisição a fila actual de pessoas elegíveis pela canDecide/empresa/departamento/conflitos/contexto e exclui manutenção. Sem pessoas elegíveis fica aviso, pedido mantém estado. Não é atribuição exclusiva nem aviso enviado. Não altera sequência/aprovações/regras. 40 testes/403 asserções, tipos/lint/build/análise dos serviços, 18 ficheiros de núcleo e HTTP local passaram; proofs/approval-routing.json. Sem migração/grants. Próxima acção: persistência de tarefas e avisos internos ligados ao evento confirmado. Subacções de permissões abrangentes ainda não seleccionáveis independentemente; exigir evolução real do núcleo, não só UI. + +**Avisos internos (01/11/2026):** migration46 workflow_notices instalada só em8005, backup/restauro e106 tabelas preservados; proofs/workflow-notices-install.json. Port do núcleo cria aviso único por etapa/destinatário com submissão/decisão na mesma transacção, sem alterar núcleo distribuído/regras. /requisicoes/avisos, filtro por ler, paginação e ligação ao pedido; ler não aprova, auditado/idempotente. Revalida destinatário/empresa/contexto/canDecide; avisos obsoletos deixam lista activa mas ficam guardados. Sem email/outbox externo, retroactividade, lembretes, delegação ou tomada exclusiva. Acessos novos em etapa já aberta ainda dependem de Pendentes para mim.42 testes/455 asserções dirigidos passaram; geral/PG a concluir. Não declarar TC01–TC14 completos. + +**Verificação final dos avisos:** geral891 testes/889 passaram/2ignorados/9236 asserções; PG21 testes/244 asserções de requisição/contextos passaram; tipos/build/lint/análise/HTTP passaram. proofs/workflow-notices.json. Teste de relatório de OrganizationConfigurationTest usa transacção externa incompatível com guarda PG de leitura própria; não alterado nem certificado em PG, coberto na bateria habitual. Próximo: reencaminhamento controlado de etapas já abertas e aviso de resultado ao requisitante; sem novo motor/decisão repetida. + +**Resultados ao requisitante (01/11/2026):** WorkflowNotices::outcome cria aviso final approved/rejected na tabela46 existente, atómico com decisão/auditoria/orçamento e único por etapa/destinatário. /requisicoes/avisos separa tarefas/resultados/porler; menu inclui requisitante. Resultado histórico e estadoactual distintos; só destinatário com acesso actual consulta/lê, sem conteúdo reservado. Sem migração/email/grants/retroactividade.45 testes/573 asserções e PG24/362 passaram; tipos/lint/build/análise/HTTP e18 ficheiros de núcleo passaram. proofs/workflow-outcomes.json. Próximo: reencaminhamento controlado de etapas abertas após mudança de perfis, sem duplicar decisão nem conceder competências. + +**Actualizar encaminhamento (01/11/2026):** acção recolhida em requisição pendente para admin/Compras fora de manutenção. Motivo+versão+etapa+chave; cria só avisos em falta a elegíveis actuais, sem escolher destinatários arbitrários ou mudar decisões/competências/etapas. Recibo lógico na auditoria imutável, idempotência sob bloqueio, falha de auditoria reverte avisos; leitura anterior e avisos antigos preservados. Perfil alterado não desencadeia esta acção automaticamente.47 testes/602 asserções e PG26/391, tipos/lint/build/análise e18 ficheiros comuns passaram; proofs/workflow-routing-refresh.json. Sem migração/email/grants. Pendências: lembretes/calendários configurados, delegação formal e automatização de reconciliação com âmbito explícito após alteração de perfis. + +**Proposta recebida de requisições ao armazém (01/11/2026):** fonte attachment e9adc3f7-1b4f-488c-a3fe-f53b310a1e64/Texto colado.txt. Enquadramento em desenvolvimento.md; não activada nem confundida com fluxo aprovado. Pedido ao armazém separado de compra, reutiliza origens/cadastros; reservas actuais SupplyCoordination são planeamento, não bloqueio físico. Novo compromisso exige integrar TODOS os caminhos que reduzem disponibilidade no único StockWorkflow, saída parcial+consumo da reserva atómicos, libertação só do restante, reconciliação autorizada. Termo saída por entrega distinto de abate por inutilização. Não activar vendas/integrações nem matriz ilustrativa automaticamente; sem inventar alçadas/prazos. CA01–09 são plano, não provas passadas. Nesta análise só documentação, sem testes de aplicação ou dados alterados. Próxima acção: vínculo necessidade planeada→pedido armazém→compromisso físico e responsabilidades/configuração antes de alterar disponibilidade. + +**Disponibilidade para requisições (01/11/2026):** /stock/disponibilidade, menu Armazém e activos, reutiliza StockWorkflow/InventoryLots e políticas/necessidades de SupplyPlanning. Físico/utilizável/retido/planeado separados, compromisso físico desconhecido explícito; sem movimentos/reservas/compras em GET. Nova capacidade warehouse_availability_read configurável sem grants.16 testes/123 asserções, tipos/lint/build/análise passaram. Proprietário determinou compra por inexistência/mínimo (AGENTS.md); geração ainda NÃO implementada, UI explícita. Próximo: configuração da origem/requisitante/centro/quantidade e comando de geração idempotente sem duplicar reposição em curso, preservando aprovações; não inventar dados nem activar vendas por inferência. + +**Destino do pedido — proprietário01/11/2026:** explicitar onde será usado; equipamento seleccionado do cadastro quando aplicável; venda ligada a FACTURA DE VENDA/linhas. Facturas actuais de fornecedor não servem de origem comercial. Condicionar campos ao destino, preservar referência/versão e transmitir vínculo à compra por falta. Não duplicar abate da origem externa nem activar vendas por inferência. Requisito incorporado em AGENTS.md; pedido/campos ainda por implementar, consulta de disponibilidade não os substitui. + +**Rascunhos ao armazém (01/11/2026):** /stock/requisicoes, migração47 instalada apenas8005, backup/restauro107 tabelas preservadas. Snapshot interno local/finalidade/centro/requisitante/artigos; equipamento por linha do cadastrodept, peças com vínculo validado. Tarefas warehouse_request_prepare/read semgrants; próprio vs consultaempresa separados. Idempotente, auditoriaatómica, sem stock/orçamento/compra ou autorização. Venda/factura de venda indisponível até fonte comercial válida; não associar factura de fornecedor.81testes/809asserções e PG6/66, tipos/lint/build/análise/HTTP passaram; proofs/warehouse-drafts.json. Sem edição/submissão/autorização/reserva/entrega ainda; próxima: edição versionada e configuração/submissão/autorização. Geração automática de compra por falta/mínimo permanece requisito a executar com origem/requisitante/centro/quantidade configurados. + +**Decisão confirmada do proprietário — documento de saída interno e venda no SGF (01/11/2026):** uso interno não tem factura. A requisição autorizada é o documento de saída do armazém; só a entrega física confirmada gera o movimento pela quantidade efectivamente entregue. Preparação/aprovação não abatem stock, e entrega parcial conserva saldo pendente. A opção de venda a terceiros será incorporada no SGF, que já dispõe de facturação, gestão de stock e contabilidade; nessa composição a requisição associa-se à factura de venda e reutiliza os mecanismos do SGF, sem duplicar movimentos ou motores. Esta decisão esclarece o âmbito comercial da proposta anterior e substitui qualquer interpretação de exigir factura no uso interno ou de criar facturação de vendas autónoma no SGC. Não remove por inferência compras/importações para revenda; trata do atendimento de vendas e documento de saída. Orientação incorporada em AGENTS.md; nesta confirmação não houve alteração de código, dados ou instalação e não foram necessários novos testes. + +**Autorização/entrega interna sem factura (01/11/2026):** migração48 eventos instalada só8005 com backup/restauro108 tabelas preservadas. /stock/requisicoes/{id} imprime requisição e entregas; submit, authorize/reject, deliver parcial/integral e receive. Tarefas review departamental independente e deliver+warehouse semgrants; requisitante não aprova/entrega, aprovador não entrega. Parcial aceite na submissão e autorizado. Cada entrega chama StockWorkflow issue; evento/auditoria/movimentos atómicos, repetição/versão controladas; recepção não movimenta stock.85testes/495asserções e PG12/122+2disputas reais passaram; geral a concluir. Reserva exclusiva e compra automática ainda NÃO implementadas, UI explícita. Não declarar CA01–09/totalidade proposta. Cancelamento, versão, delegação, avisos novos e destinatário alternativo pendentes. proofs/warehouse-fulfilment-install.json. + +**Verificação final da autorização/entrega:** bateria geral908 testes/906 passaram/2ignorados/9508 asserções, sem falhas. PostgreSQL12/122 e2disputas reais já descritos; prova consolidada docs/proofs/warehouse-fulfilment.json. O acesso operacional exige atribuição explícita das tarefas warehouse_request_prepare/review/deliver e, para entregar, warehouse. Sem atribuições automáticas. Não se confundem estes resultados com reserva exclusiva ou geração automática de compras, que continuam pendentes. diff --git a/docs/proofs/approval-routing-http.json b/docs/proofs/approval-routing-http.json new file mode 100644 index 0000000..7e50b0f --- /dev/null +++ b/docs/proofs/approval-routing-http.json @@ -0,0 +1,10 @@ +{ + "checked_at": "2026-11-01T16:31:19.533Z", + "environment": "http://127.0.0.1:8005", + "profile_task_catalogue": "passed", + "workflow_notices": "ready", + "existing_requisition_routing": "inactive", + "business_commands": false, + "accounts_or_grants_changed": false, + "visual_browser_review": false +} \ No newline at end of file diff --git a/docs/proofs/approval-routing.json b/docs/proofs/approval-routing.json new file mode 100644 index 0000000..0b5c2c0 --- /dev/null +++ b/docs/proofs/approval-routing.json @@ -0,0 +1,24 @@ +{ + "checked_at": "2026-11-01T14:48:47.5015167Z", + "tests": { + "tool": "pest", + "result": "passed", + "tests": 40, + "passed": 40, + "assertions": 403, + "duration_ms": 5186 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "http_proof": "approval-routing-http.json", + "runtime_files_checked": 18, + "visual_browser_review": false, + "persistent_tasks_implemented": false, + "notifications_sent": false, + "grants_changed": false +} diff --git a/docs/proofs/candidate-flow-production.json b/docs/proofs/candidate-flow-production.json new file mode 100644 index 0000000..fddd244 --- /dev/null +++ b/docs/proofs/candidate-flow-production.json @@ -0,0 +1,245 @@ +{ + "checked_at": "2026-10-30T19:32:51.598464+00:00", + "server": { + "active_commit": "3b1ef2031ce395d21d973d0e7a7ec6630f058db8", + "expected_commit_in_release": true, + "deployment_status": "Result=success\nExecMainStatus=0\nActiveState=inactive\nSubState=dead\n", + "nginx_validation_ok": true, + "storage_in_shared": true, + "web_user_can_write_private_uploads": true, + "application": { + "database_driver": "pgsql", + "migration_count": 44, + "candidate_migrations": [ + "2026_10_30_000037_create_candidate_profiles", + "2026_10_30_000038_create_candidate_access_tokens", + "2026_10_30_000039_create_candidate_submissions", + "2026_10_30_000040_create_candidate_triage", + "2026_10_30_000041_create_candidate_submission_documents" + ], + "documents_table_exists": true, + "document_count": 0, + "public_navigation_enabled": true, + "candidate_disk_root": "/var/www/compras-deploy/releases/20261030-192546-3b1ef2031ce3-Hvig1c3b/storage/app/candidate-documents" + } + }, + "http": { + "checked_at": "2026-10-30T19:26:31.442913+00:00", + "target_commit": "3b1ef2031ce395d21d973d0e7a7ec6630f058db8", + "results": [ + { + "path": "/", + "status": 302, + "location": "https://compras.tempo.co.mz/dashboard" + }, + { + "path": "/dashboard", + "status": 200, + "component": "dashboard", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/requisicoes", + "status": 200, + "component": "requisitions/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/requisicoes/criar", + "status": 200, + "component": "requisitions/form", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/fornecedores", + "status": 200, + "component": "suppliers/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/cotacoes", + "status": 200, + "component": "quotations/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/ordens", + "status": 200, + "component": "purchases/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/recepcoes", + "status": 200, + "component": "receipts/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/stock", + "status": 200, + "component": "stock/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/stock/catalogo", + "status": 200, + "component": "stock/catalogue", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/stock/operacoes", + "status": 200, + "component": "stock/operations", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/equipamentos", + "status": 200, + "component": "equipment/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/planeamento", + "status": 200, + "component": "planning/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/pessoas", + "status": 200, + "component": "people/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/orcamentos", + "status": 200, + "component": "budgets/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/facturas", + "status": 200, + "component": "invoices/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/pagamentos", + "status": 200, + "component": "payments/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/regras-investimento", + "status": 200, + "component": "investment/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/politica-admissao", + "status": 200, + "component": "purchases/admission-policy", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/politica-recomendacao", + "status": 200, + "component": "purchases/recommendation-policy", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/revisao-compras", + "status": 200, + "component": "purchase-controls/index", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/settings/appearance", + "status": 200, + "component": "settings/appearance", + "anonymous": true, + "data_scope": "none", + "version": "dc62759c2f30f3d4639ed3f4af3e38f9" + }, + { + "path": "/pessoas/cadastro", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/pessoas/1", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/fornecedores/1/dados-bancarios", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/administracao/parametrizacao", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/relatorios", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/settings/profile", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/candidaturas/1/submissoes/1/documentos/cv", + "status": 401 + } + ], + "public_pages_passed": 21, + "private_routes_protected": 7, + "server_activation_proof": "confirmed" + }, + "new_migrations_applied": false, + "visual_review": "pending", + "real_candidate_end_to_end_trial": "pending; no real submissions created" +} diff --git a/docs/proofs/internal-requisitions-candidate.json b/docs/proofs/internal-requisitions-candidate.json new file mode 100644 index 0000000..f819177 --- /dev/null +++ b/docs/proofs/internal-requisitions-candidate.json @@ -0,0 +1,17 @@ +{ + "candidate_id": "IR-CANDIDATE-bfa807e2b3c4fae7", + "directory": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7", + "manifest_sha256": "dee8e030763b6cc277059d91497f66c02233630e8416e7417733e9d51f471b9c", + "archives": { + "runtime": { + "file": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7/runtime.zip", + "sha256": "5a63a283a21c9c8a7fdd837da8a0725cfe28cd2653eac61884e8b64f30c38326" + }, + "verification": { + "file": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7/verification.zip", + "sha256": "02776c0097ab27fcb6269e647ce6b10c04a6731be56de54ceac2890e7bbac325" + } + }, + "standard_updater_refuses_candidate": true, + "activation_performed": false +} \ No newline at end of file diff --git a/docs/proofs/internal-requisitions-sgf.json b/docs/proofs/internal-requisitions-sgf.json new file mode 100644 index 0000000..5606189 --- /dev/null +++ b/docs/proofs/internal-requisitions-sgf.json @@ -0,0 +1,85 @@ +{ + "passed": true, + "checks": [ + "fresh isolated database", + "failed additive migration rolls back preserving existing identity grants and budget", + "successful additive migration preserves existing data", + "authenticated synthetic identity 1", + "authorised company unit selection 1", + "authenticated synthetic identity 2", + "authorised company unit selection 2", + "versioned policy without functional grants", + "authorised workflow screen and context", + "create draft through HTTP", + "CSRF retained on candidate commands", + "create retry one origin", + "protected context field refused", + "wrong work area refused", + "stale context refused on reads", + "stale edit refused", + "failure after reserve injected", + "failure rolls back origin reserve steps and request", + "committed response loss injected", + "exact retry recovers committed submission", + "single existing budget reservation", + "self approval denied with approval grant", + "revoked approval denied at server", + "independent approval", + "approval retry preserves one decision and versions", + "authorised origin version and history consultation", + "revoked read denied", + "unit selection does not grant access", + "explicit authorised unit switch", + "read and operation from previous unit blocked", + "explicit authorised company switch without name matching", + "other company cannot read origin", + "return to original authorised scope", + "first transaction owns scope lock save", + "concurrent retry serialized without duplicate save", + "first transaction owns scope lock submit", + "concurrent retry serialized without duplicate submit", + "first transaction owns scope lock decide", + "concurrent retry serialized without duplicate decide", + "concurrent whole lifecycle produced one request reserve per origin", + "history SQL immutability", + "failed schema transaction recovered", + "old candidate reads additive compatible schema without rewriting data" + ], + "concurrency": [ + "save", + "submit", + "decide" + ], + "scope": { + "tenant_id": 1, + "company_id": 2, + "unit_id": 2 + }, + "requests": 2, + "budget_reservations": 2, + "schema_failure_recovered": true, + "additive_schema_compatible": true, + "active_destination_changed": false, + "data_schema_recovery": { + "backup_sha256": "c283b6fdcc6b18c9455bab721790f640235a9b87b5aaa9846a6ed40cd04c9b0f", + "tables_reconciled": 22, + "restore_verified": true, + "schema_constraints_and_triggers_verified": true, + "restored_consultation": { + "restored_candidate_consultation": true, + "id": 1, + "origin_preserved": true, + "version": 3 + }, + "restores_new_operations_automatically": false, + "separate_from_code_rollback": true + }, + "source_postgres_tests": { + "passed": true, + "output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":46,\"passed\":46,\"assertions\":339,\"duration_ms\":23459}" + }, + "checked_at": "2026-11-01T13:56:52+00:00", + "isolated_database_removed_in_finally": true, + "active_destination_version": "0.1.1", + "candidate_root": "C:\/laragon\/www\/compras\/storage\/app\/private\/candidates\/IR-CANDIDATE-bfa807e2b3c4fae7" +} \ No newline at end of file diff --git a/docs/proofs/internal-requisitions-validation.json b/docs/proofs/internal-requisitions-validation.json new file mode 100644 index 0000000..fd4450d --- /dev/null +++ b/docs/proofs/internal-requisitions-validation.json @@ -0,0 +1,58 @@ +{ + "candidate_id": "IR-CANDIDATE-bfa807e2b3c4fae7", + "checked_date": "2026-11-01", + "source_full_sqlite_suite": { + "tool": "pest", + "result": "passed", + "tests": 883, + "passed": 881, + "assertions": 8950, + "duration_ms": 171488, + "skipped": 2 + }, + "source_postgresql": { + "tests": 46, + "assertions": 339, + "passed": true, + "isolated": true + }, + "sgf_candidate": { + "checks": 43, + "passed": true, + "isolated": true, + "proof": "internal-requisitions-sgf.json" + }, + "screen_logic": { + "checks": 23, + "passed": true, + "browser_used": false + }, + "phpstan": { + "errors": 0, + "passed": true, + "scope": "InternalRequisitionPort, RequisitionWorkflow, Requisition model and shared InternalRequisitions" + }, + "typescript": { + "passed": true + }, + "frontend_lint": { + "passed": true, + "files": 4, + "warnings": 0 + }, + "frontend_build": { + "passed": true + }, + "candidate": "internal-requisitions-candidate.json", + "destination_version": "0.1.1", + "activated": false, + "published": false, + "public_rights_changed": false, + "data_schema_recovery_separate_from_code": true, + "limitations": [ + "SGF equipment/spare-part technical workflows not incorporated", + "Operational migration installation and administrative permission catalogue not activated", + "No browser visual review; HTTP, screen logic and compilation verified", + "Two tests skipped in full suite; not counted as passed" + ] +} diff --git a/docs/proofs/inventory-corrections.json b/docs/proofs/inventory-corrections.json new file mode 100644 index 0000000..7f32fda --- /dev/null +++ b/docs/proofs/inventory-corrections.json @@ -0,0 +1,191 @@ +{ + "checked_at": "2026-11-01T12:18:05.919263Z", + "scope": [ + "counts_and_signed_adjustments", + "supplier_returns_and_writeoffs", + "approved_lot_policy_revisions", + "full_physical_reversals", + "calendar_source_validity" + ], + "postgresql": { + "checked_at": "2026-11-01T14:16:58+02:00", + "database": "fresh disposable PostgreSQL", + "tests": [ + "SupplyPlanningTest", + "SupplyCoordinationTest", + "SupplySuggestionsTest", + "SupplyHistoryTest", + "InventoryLotsTest", + "InventoryCountsTest", + "InventoryDispositionsTest", + "InventoryLotPolicyTest", + "InventoryReversalsTest" + ], + "result": "passed", + "races": [ + { + "scenario": "same_reference", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "same_operation", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "single_conversion", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "conversion_retry", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_issue", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_identification_release", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_transfer", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_adjustment", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "double_supplier_return", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_policy_stock_change", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "double_stock_reversal", + "real_lock_contention": true, + "result": "passed" + } + ], + "persistent_data_changed": false, + "output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":98,\"passed\":98,\"assertions\":628,\"duration_ms\":55755}" + }, + "navigation": { + "checked_at": "2026-11-01T12:17:16.378Z", + "checks": [ + { + "view": "agreements", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "calendar", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "imports", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "alternatives", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "reservations", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "deliveries", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "suggestions", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "history", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "lots", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "dispositions", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "reversals", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + } + ] + }, + "static_analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "directed_tests": { + "tool": "pest", + "result": "passed", + "tests": 23, + "passed": 23, + "assertions": 140, + "duration_ms": 4688 + }, + "types": "passed", + "build": "passed", + "runtime_files_sha256_checked": 18, + "visual_browser_review": false, + "financial_credit_or_refund_implemented": false, + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 862, + "passed": 860, + "assertions": 8527, + "duration_ms": 122157, + "skipped": 2 + }, + "sources": { + "app/Services/SupplyPlanning.php": "ACC8005DADF78CC3B91D5EB3F1D44322E60B44920DBD5CA420D4A8F3ACEC586A", + "app/Services/InventoryLots.php": "DF1672C5D6EC7D96A79581555408287B5449C06286B53359D8472659E3BA8FE7", + "app/Services/InventoryDispositions.php": "1CF64BABC5659A3C056600A2E8ABCC7B88D11E37097AB3ED6E339313C392749F", + "app/Services/StockWorkflow.php": "1432454AB657EF8EF40886E4676ECAF5EE5A40E6D5782BCB25C2F88D573D33A7", + "app/Services/InventoryCounts.php": "0C1B0D5E795A15B4D762C47FCDBEB19F225AA933B4507938F310FF25026AFA11", + "app/Services/InventoryReversals.php": "AA17B3F30F65A855173DFFEF0F4659B7716D54D9CD622FEB5E3CC44208CEEBEE" + } +} diff --git a/docs/proofs/inventory-count-install.json b/docs/proofs/inventory-count-install.json new file mode 100644 index 0000000..4df068b --- /dev/null +++ b/docs/proofs/inventory-count-install.json @@ -0,0 +1,12 @@ +{ + "checked_at": "2026-11-01T10:53:28+00:00", + "migration": "2026_11_01_000044_allow_signed_lot_adjustments", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-105325\/before.dump", + "backup_sha256": "496c0ea53dece4d8714a696d60db75eef4524af6d72a971503a1d1b6f3cd3986", + "restore_verified": true, + "existing_tables_preserved": 106, + "new_tables": [], + "accounts_or_grants_changed": false, + "domain_installed": false +} \ No newline at end of file diff --git a/docs/proofs/inventory-lot-install.json b/docs/proofs/inventory-lot-install.json new file mode 100644 index 0000000..333b953 --- /dev/null +++ b/docs/proofs/inventory-lot-install.json @@ -0,0 +1,14 @@ +{ + "checked_at": "2026-11-01T10:30:58+00:00", + "migration": "2026_11_01_000043_create_inventory_lot_allocations", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-103055\/before.dump", + "backup_sha256": "4727a6859e19a33c2fa0ad91a925e75737490590355856a3b150f6bb75748e5b", + "restore_verified": true, + "existing_tables_preserved": 105, + "new_tables": { + "inventory_lot_allocations": 0 + }, + "accounts_or_grants_changed": false, + "domain_installed": false +} \ No newline at end of file diff --git a/docs/proofs/inventory-lot-transfers.json b/docs/proofs/inventory-lot-transfers.json new file mode 100644 index 0000000..b1070aa --- /dev/null +++ b/docs/proofs/inventory-lot-transfers.json @@ -0,0 +1,87 @@ +{ + "visual_browser_review": false, + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 808, + "passed": 806, + "assertions": 8230, + "duration_ms": 117137, + "skipped": 2 + }, + "types_build_lint_scoped_phpstan": "passed", + "postgresql": { + "checked_at": "2026-11-01T12:39:20+02:00", + "database": "fresh disposable PostgreSQL", + "tests": [ + "SupplyPlanningTest", + "SupplyCoordinationTest", + "SupplySuggestionsTest", + "SupplyHistoryTest", + "InventoryLotsTest" + ], + "result": "passed", + "races": [ + { + "scenario": "same_reference", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "same_operation", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "single_conversion", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "conversion_retry", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_issue", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_identification_release", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_transfer", + "real_lock_contention": true, + "result": "passed" + } + ], + "persistent_data_changed": false, + "output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":56,\"passed\":56,\"assertions\":417,\"duration_ms\":26151}" + }, + "sources_sha256": { + "tests/Concurrency/supply-worker.php": "56bc8d55ea07daac7ad4472a7a408b822e7d3ee7d97d948b1ca754497f0ced77", + "app/Services/StockWorkflow.php": "5ff22b088b79a2995b0b1480efc6eaba2c119ab2417ffe5ec7ef1951327a3056", + "resources/js/pages/stock/operations.tsx": "0580ba8c67e5e0368068c63948778af06a53bba3afb76ed00180c70e986dea61", + "resources/js/pages/stock/lots.tsx": "6d0839fd85d8803d5c59dfc12ac6162630e2a7962d45e7ac25438eafa97995ff", + "app/Services/InventoryLots.php": "6afbfffa32e8b55ebe916b14d681e72f1744a31e0f5d3e4c8732657b19b99c7a", + "tests/Feature/InventoryLotsTest.php": "b14fbbf49ac12ab0e7e882cbf961e7bfc8845bba76f9cd583ef1aa53c51d5ced", + "scripts/supply-pg-check.php": "7561ea31c0fcecad839678df5a2cd0f7d9a904c4c589413682754c5b8d79b097" + }, + "persistent_business_commands_or_grants": false, + "remaining": [ + "Counts and adjustments partitioned by lot", + "Returns, write-offs and reversals", + "Separate dispatch, transit and physical receipt", + "Movement of held/expired material and manual lot selection", + "Partial quarantine within one lot identity", + "Existing Word proposal gaps retained in continuity documents" + ], + "new_migration": false, + "checked_at": "2026-11-01T10:41:07.6014744Z", + "published": false, + "navigation": "nine installed local HTTP/Inertia pages verified", + "scope": "Immediate tracked warehouse transfers, stable lot identity, FEFO allocation at both ends and independent destination quarantine" +} diff --git a/docs/proofs/inventory-lots.json b/docs/proofs/inventory-lots.json new file mode 100644 index 0000000..60cef34 --- /dev/null +++ b/docs/proofs/inventory-lots.json @@ -0,0 +1,46 @@ +{ + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 801, + "passed": 799, + "assertions": 8192, + "duration_ms": 112879, + "skipped": 2 + }, + "final_targeted_suite": { + "tool": "pest", + "result": "passed", + "tests": 91, + "passed": 91, + "assertions": 596, + "duration_ms": 10632 + }, + "postgresql": "49 tests / 379 assertions and six real lock-contention races; see supply-coordination-pg.json", + "scope": "Initial lot reconciliation, reception, metadata, independent quarantine release, FEFO issue and expiry projection", + "local_navigation": "nine HTTP/Inertia pages verified; no automatic write permissions", + "limitations": [ + "Tracked transfers and adjustments pending, guarded on server", + "Active setup revision and exemption from quarantine pending", + "Explicit identity reuse across receipts pending", + "Unqualified future lot arrivals excluded from usable forecast", + "Full traceability reporting, alerts and pagination pending" + ], + "published": false, + "checked_at": "2026-11-01T10:34:21.9942049Z", + "sources_sha256": { + "app/Http/Controllers/InventoryLotController.php": "3089578e142b98b60bb4561a235550772d9108114ac005e489cadf74a0ce4891", + "tests/Feature/InventoryLotsTest.php": "813ea1d7dc937d1e6d8f756f6d9d9acc97a12c2640d97d5b8bcb53c441014c11", + "resources/js/pages/stock/lots.tsx": "d36035c74245ed927c90a2e6ac28d4a3ec5f5c61298b5e75f117761b61167432", + "app/Services/ReceiptWorkflow.php": "9b3e0bca27852bc04de67b0a8d075be1acabddee4b309e82d06983f99bcbcf94", + "app/Services/ReplenishmentProjection.php": "876dea250546044db7993fe62d1c189f3298d43bd5342664d25c6e4d54b1176c", + "app/Services/InventoryLots.php": "735702065b8d5a864a44927e5bd67d1f70a32d048583476c534ada56bd02380b", + "app/Services/SupplySuggestions.php": "3f304611f63340316b56065d8cbd3e9f2ebcbb968c1bbac74d181f0eabfacb7d", + "database/migrations/2026_11_01_000043_create_inventory_lot_allocations.php": "37c7ab878c59a8eb5bcbe8a1e7e479199e308dc8c67d0d52ec8d6156b3379ecc", + "app/Services/StockWorkflow.php": "27099a811beab57a1b3d1ee3ee5bda49715c7ac7a101ba0e95532ada21e52d10", + "app/Models/InventoryLotAllocation.php": "3b2b1aa7be0bb5032ab4e0b6ec5a768811566aebc1d690f682e7bd27477c93e7" + }, + "types_build_lint_scoped_phpstan": "passed", + "visual_browser_review": false, + "local_installation": "migration 43; verified backup restore; 105 existing tables preserved; no new accounts, grants or business data" +} diff --git a/docs/proofs/people-applications-report.json b/docs/proofs/people-applications-report.json new file mode 100644 index 0000000..791497d --- /dev/null +++ b/docs/proofs/people-applications-report.json @@ -0,0 +1,21 @@ +{ + "checked_at": "2026-11-01T13:26:45.0495260Z", + "tests": { + "tool": "pest", + "result": "passed", + "tests": 76, + "passed": 76, + "assertions": 1683, + "duration_ms": 11767 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "local_http": "report and CSV for both views passed", + "visual_browser_review": false, + "existing_data_or_grants_changed": false +} diff --git a/docs/proofs/people-deadline-filters.json b/docs/proofs/people-deadline-filters.json new file mode 100644 index 0000000..c220f07 --- /dev/null +++ b/docs/proofs/people-deadline-filters.json @@ -0,0 +1,21 @@ +{ + "checked_at": "2026-11-01T14:26:36.4015912Z", + "tests": { + "tool": "pest", + "result": "passed", + "tests": 82, + "passed": 82, + "assertions": 2028, + "duration_ms": 30608 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "local_http": "four report views, four exports, four deadline filters passed", + "visual_browser_review": false, + "data_or_grants_changed": false +} diff --git a/docs/proofs/people-deadlines-report.json b/docs/proofs/people-deadlines-report.json new file mode 100644 index 0000000..f5db642 --- /dev/null +++ b/docs/proofs/people-deadlines-report.json @@ -0,0 +1,22 @@ +{ + "checked_at": "2026-11-01T14:21:45.8615228Z", + "tests": { + "tool": "pest", + "result": "passed", + "tests": 80, + "passed": 80, + "assertions": 1903, + "duration_ms": 18286 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "http_proof": "people-reports-http.json", + "visual_browser_review": false, + "business_data_changed": false, + "grants_changed": false +} diff --git a/docs/proofs/people-reports-http.json b/docs/proofs/people-reports-http.json new file mode 100644 index 0000000..8a75c22 --- /dev/null +++ b/docs/proofs/people-reports-http.json @@ -0,0 +1,41 @@ +{ + "checked_at": "2026-11-01T14:26:12.336Z", + "environment": "http://127.0.0.1:8005", + "pages": [ + { + "path": "/pessoas/relatorios", + "status": 200 + }, + { + "path": "/pessoas/relatorios.csv", + "status": 200 + }, + { + "path": "/pessoas/relatorios?view=applications", + "status": 200 + }, + { + "path": "/pessoas/relatorios.csv?view=applications", + "status": 200 + }, + { + "path": "/pessoas/relatorios?view=selections", + "status": 200 + }, + { + "path": "/pessoas/relatorios.csv?view=selections", + "status": 200 + }, + { + "path": "/pessoas/relatorios?view=deadlines", + "status": 200 + }, + { + "path": "/pessoas/relatorios.csv?view=deadlines", + "status": 200 + } + ], + "business_commands": false, + "accounts_or_grants_changed": false, + "visual_browser_review": false +} \ No newline at end of file diff --git a/docs/proofs/people-reports.json b/docs/proofs/people-reports.json new file mode 100644 index 0000000..7e984b9 --- /dev/null +++ b/docs/proofs/people-reports.json @@ -0,0 +1,21 @@ +{ + "checked_at": "2026-11-01T12:24:50.9407640Z", + "tests": { + "tool": "pest", + "result": "passed", + "tests": 52, + "passed": 52, + "assertions": 1188, + "duration_ms": 9487 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "http_proof": "people-reports-http.json", + "migration_required": false, + "access_grants_changed": false +} diff --git a/docs/proofs/people-selections-report.json b/docs/proofs/people-selections-report.json new file mode 100644 index 0000000..faca558 --- /dev/null +++ b/docs/proofs/people-selections-report.json @@ -0,0 +1,22 @@ +{ + "checked_at": "2026-11-01T13:40:40.9440516Z", + "tests": { + "tool": "pest", + "result": "passed", + "tests": 78, + "passed": 78, + "assertions": 1839, + "duration_ms": 13966 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "http": "three report views and their CSV exports passed", + "visual_browser_review": false, + "existing_business_data_changed": false, + "grants_changed": false +} diff --git a/docs/proofs/purchasing-client-independence.json b/docs/proofs/purchasing-client-independence.json new file mode 100644 index 0000000..db93c76 --- /dev/null +++ b/docs/proofs/purchasing-client-independence.json @@ -0,0 +1,23 @@ +{ + "checked_at": "2026-11-01T12:58:15.7857457Z", + "installed_client": { + "version": "0.1.1", + "runtime": "8.4.27", + "all_loaded_code_in_client_host": true, + "source_checkout_required": false, + "database_connected": false, + "quantity_milli": 84000 + }, + "isolated_test": { + "assertions": 29, + "source_directory_renamed_unavailable": true, + "all_child_loaded_code_in_synthetic_client": true, + "tests_passed": 6 + }, + "rollback_scope": "verified code pointer only", + "schema_or_data_rollback_implemented": false, + "future_migrations_verified": false, + "active_sgf_version": "0.1.1", + "prepared_not_installed": "0.1.2", + "expansion_interrupted_before_activation": true +} \ No newline at end of file diff --git a/docs/proofs/purchasing-transfer.json b/docs/proofs/purchasing-transfer.json new file mode 100644 index 0000000..1bbf76f --- /dev/null +++ b/docs/proofs/purchasing-transfer.json @@ -0,0 +1,36 @@ +{ + "checked_at": "2026-11-01T12:37:11.1059289Z", + "scope": "first purchasing capability and release update mechanism", + "installed_version": "0.1.1", + "previous_version_preserved": "0.1.0", + "manifest_sha256": "91ba35ee3aa1ca226052c94ed682e430cb7870beb241f16b17d889046e1f711d", + "engine_sha256": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d", + "identical_engine_in_both_hosts": true, + "origin_tests": { + "assertions": 566, + "passed": 86 + }, + "host_tests": { + "assertions": 11, + "runtime": "PHP 8.4.27", + "database_used": false, + "passed": 2 + }, + "update_cycle": [ + "install 0.1.0", + "update 0.1.1", + "rollback 0.1.0", + "reactivate 0.1.1", + "retry without reinstall" + ], + "tooling_artifacts_verified": 4, + "capabilities": [ + "inventory.replenishment_projection" + ], + "all_workflows_transferred": false, + "runtime_requires_source_checkout": false, + "persistent_data_migrated": false, + "permissions_changed": false, + "dependencies_changed": false, + "domain_published": false +} \ No newline at end of file diff --git a/docs/proofs/purchasing-update-pipeline.json b/docs/proofs/purchasing-update-pipeline.json new file mode 100644 index 0000000..33b4c34 --- /dev/null +++ b/docs/proofs/purchasing-update-pipeline.json @@ -0,0 +1,36 @@ +{ + "release": { + "version": "0.1.1", + "archive": "purchasing-0.1.1.zip", + "source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250", + "manifest_sha256": "91ba35ee3aa1ca226052c94ed682e430cb7870beb241f16b17d889046e1f711d", + "changed": false, + "tests": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":86,\"passed\":86,\"assertions\":566,\"duration_ms\":12434}", + "staged_source_probe": { + "version": "0.1.0", + "source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250", + "runtime": "8.5.11", + "scenarios": [ + "manual", + "exact_historical_rate", + "lot_expiry" + ], + "passed": true, + "database_connected": false + } + }, + "plan": { + "installed_version": "0.1.1", + "candidate_version": "0.1.1", + "available": false, + "same_source": true, + "database_changes": false, + "permissions_changed": false + }, + "installation": { + "changed": false, + "version": "0.1.1" + }, + "database_changes": false, + "permissions_changed": false +} \ No newline at end of file diff --git a/docs/proofs/sgf-access-delivery.json b/docs/proofs/sgf-access-delivery.json new file mode 100644 index 0000000..cc730cf --- /dev/null +++ b/docs/proofs/sgf-access-delivery.json @@ -0,0 +1,35 @@ +{ + "passed": true, + "checks": [ + "existing administration lists new catalogue", + "unit creation grants only legacy rights", + "denied 403", + "denied 409", + "nominative authorised initialisation choices", + "initial authority screen selects explicit rights and includes context and CSRF", + "screen has no selected grants and retains operation key on retry", + "denied 422", + "audit failure rolls back initial grants", + "explicit nominated administrator initialisation", + "initial authority audited", + "unselected rights absent", + "initialisation retry idempotent", + "initialised screen cannot offer a second initial grant", + "normal administrative grant", + "normal administrative revocation", + "denied 403", + "denied 403", + "failed cascade preserves unit access", + "unit revocation removes derived catalogue rights", + "initialisation replay never resurrects revoked rights", + "denied 409", + "denied 403", + "denied 403" + ], + "installed": false, + "overlay": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/ACCESS-CANDIDATE-a69f06e21747228a", + "checked_at": "2026-11-01T14:19:42+00:00", + "destination_version": "0.1.1", + "prior_tests_repeated": false, + "isolated_database_removed": true +} \ No newline at end of file diff --git a/docs/proofs/sgf-composition-delivery.json b/docs/proofs/sgf-composition-delivery.json new file mode 100644 index 0000000..8dc6421 --- /dev/null +++ b/docs/proofs/sgf-composition-delivery.json @@ -0,0 +1,41 @@ +{ + "passed": true, + "checks": [ + "provider really loaded from isolated copy", + "budget adapter really loaded from isolated copy", + "new bundle bootstrap accepted by installer integrity check", + "anonymous direct initialisation URL denied", + "native clone authentication 1", + "native clone context 1", + "native clone authentication 2", + "native clone context 2", + "provider registers initial authority page without prior functional rights", + "initial authority CSRF enforced", + "initial authority through normal middleware", + "work page requires explicit module selection", + "new requisition view right makes purchasing selectable", + "stale module generation refused", + "provider registers operational work page", + "different enterprise option refused by operational composition", + "native administrative delegation purchasing_requisitions.view", + "native administrative delegation purchasing_requisitions.approve.department_head", + "operational policy configured via HTTP", + "integrated operational draft", + "integrated submit uses existing budget adapter", + "integrated independent identity enforced", + "integrated approval by another identity", + "integrated authorised consultation and history", + "native revocation applies to direct module URL", + "uncontracted module refused at server", + "disabled composition exposes no executable work page" + ], + "clone": "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-composition-f4de0d0473a5", + "destination_activated": false, + "normal_provider_routes": true, + "browser_used": false, + "overlay": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-4b25f81932efd69d", + "checked_at": "2026-11-02T06:07:41+00:00", + "destination_version": "0.1.1", + "prior_tests_repeated": false, + "isolated_database_removed": true +} \ No newline at end of file diff --git a/docs/proofs/sgf-operational-delivery.json b/docs/proofs/sgf-operational-delivery.json new file mode 100644 index 0000000..42b169d --- /dev/null +++ b/docs/proofs/sgf-operational-delivery.json @@ -0,0 +1,40 @@ +{ + "passed": true, + "checks": [ + "CLI plan is read-only and identifies destination", + "CLI refuses unverified backup before schema write", + "Destino, backup ou operador n\u00e3o conferidos.", + "Schema or\u00e7amental parcial; n\u00e3o completar \u00e0s cegas.", + "Schema or\u00e7amental incompat\u00edvel com o contrato aceite.", + "clean budget schema installed without synthetic records", + "clean identity catalogue supports budget rights without automatic grants", + "Injected operational DDL failure", + "failed installation leaves no partial module or receipt", + "failed preparation preserves existing budget and rights", + "schema prepared without code activation", + "CLI records actual verified custom backup hash", + "installation retry preserves one receipt", + "successful preparation preserves grants and approved budget", + "operational composition works outside QA environment", + "operational composition reuses existing budget reservation motor", + "operational budget checks revoked permission", + "unconfigured operational unit refused", + "divergent schema refused without rewriting it", + "schema preparation history immutable" + ], + "installed_on_destination": false, + "activated": false, + "restored_operational_composition": { + "verified": true, + "production_environment": true, + "database_outside_QA_prefix": true, + "schema_receipt_and_budget_reservation_preserved": true, + "revocation_preserved": true + }, + "recovery_dump_sha256": "f1601584154c17b0b3402ec3efcaa51d5aedb58a56bb919a201193c0af5715b7", + "overlay": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5", + "checked_at": "2026-11-01T14:27:17+00:00", + "destination_version": "0.1.1", + "prior_tests_repeated": false, + "isolated_database_removed": true +} \ No newline at end of file diff --git a/docs/proofs/supply-coordination-pg.json b/docs/proofs/supply-coordination-pg.json new file mode 100644 index 0000000..8de5a03 --- /dev/null +++ b/docs/proofs/supply-coordination-pg.json @@ -0,0 +1,75 @@ +{ + "checked_at": "2026-11-01T12:16:58+00:00", + "database": "fresh disposable PostgreSQL", + "tests": [ + "SupplyPlanningTest", + "SupplyCoordinationTest", + "SupplySuggestionsTest", + "SupplyHistoryTest", + "InventoryLotsTest", + "InventoryCountsTest", + "InventoryDispositionsTest", + "InventoryLotPolicyTest", + "InventoryReversalsTest" + ], + "result": "passed", + "races": [ + { + "scenario": "same_reference", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "same_operation", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "single_conversion", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "conversion_retry", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_issue", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_identification_release", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_transfer", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_double_adjustment", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "double_supplier_return", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "lot_policy_stock_change", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "double_stock_reversal", + "real_lock_contention": true, + "result": "passed" + } + ], + "persistent_data_changed": false, + "output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":98,\"passed\":98,\"assertions\":628,\"duration_ms\":55755}" +} \ No newline at end of file diff --git a/docs/proofs/supply-coordination.json b/docs/proofs/supply-coordination.json new file mode 100644 index 0000000..6f6eb24 --- /dev/null +++ b/docs/proofs/supply-coordination.json @@ -0,0 +1,18 @@ +{ + "checked_at": "2026-11-01T09:11:35.2729856Z", + "scope": "reservations delivery confirmations and directional technical alternatives", + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 766, + "passed": 764, + "assertions": 7913, + "duration_ms": 89626, + "skipped": 2 + }, + "pg_proof": "supply-coordination-pg.json", + "navigation_proof": "supply-navigation.json", + "types_build_and_scoped_static_checks": "passed", + "visual_review": "pending", + "persistent_business_data_or_grants_changed": false +} diff --git a/docs/proofs/supply-history.json b/docs/proofs/supply-history.json new file mode 100644 index 0000000..e60fdc0 --- /dev/null +++ b/docs/proofs/supply-history.json @@ -0,0 +1,52 @@ +{ + "checked_at": "2026-11-01T10:02:59.8167650Z", + "scope": "reviewed historical consumption mean and reuse in policies calendar and preserved suggestions", + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 788, + "passed": 786, + "assertions": 8122, + "duration_ms": 98623, + "skipped": 2 + }, + "final_targeted_suite": { + "assertions": 163, + "passed": 20 + }, + "postgres_output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":36,\"passed\":36,\"assertions\":310,\"duration_ms\":15324}", + "postgres_races": [ + { + "scenario": "same_reference", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "same_operation", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "single_conversion", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "conversion_retry", + "real_lock_contention": true, + "result": "passed" + } + ], + "types_build_and_scoped_static_checks": "passed", + "navigation": "eight local pages passed", + "visual_review": "pending", + "persistent_business_data_or_grants_changed": false, + "source_sha256": { + "app/Services/SupplyHistory.php": "d706783c4c908308adb279f441737da13f2106c774ca23bf825c2ca2374418a3", + "app/Services/ReplenishmentProjection.php": "c88d5326a5c66812ebf46543f4d0bb489bff3498086a6711ed4dc53f0063078e", + "app/Services/SupplyPlanning.php": "9b0fa2fd103c35d05685e5231ddb80adf7bb65a4b27979296e86dda836ef0116", + "app/Services/SupplySuggestions.php": "b4d987ad174e381fa34304832c89e24f4874c8f0c33309a4dea49580bc140ca9", + "resources/js/pages/supply/history.tsx": "b5e9c1cb94121bb204083eb1142951ce043ca53dbb5169ede6e3b327ac728f85", + "tests/Feature/SupplyHistoryTest.php": "ff8d410137cdcfeb1d3e9126e469681e57ae6e62dff33e05ba17cf2dc80b4714" + } +} diff --git a/docs/proofs/supply-install.json b/docs/proofs/supply-install.json new file mode 100644 index 0000000..f846fc8 --- /dev/null +++ b/docs/proofs/supply-install.json @@ -0,0 +1,14 @@ +{ + "checked_at": "2026-11-01T08:48:53+00:00", + "migration": "2026_11_01_000042_create_supply_records", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-084850\/before.dump", + "backup_sha256": "18ebc3af1115eca40b478a1bf4a4de18080c15a5a73195022b45c8eea18e7256", + "restore_verified": true, + "existing_tables_preserved": 104, + "new_tables": { + "supply_records": 0 + }, + "accounts_or_grants_changed": false, + "domain_installed": false +} \ No newline at end of file diff --git a/docs/proofs/supply-navigation.json b/docs/proofs/supply-navigation.json new file mode 100644 index 0000000..f4502eb --- /dev/null +++ b/docs/proofs/supply-navigation.json @@ -0,0 +1,71 @@ +{ + "checked_at": "2026-11-01T12:17:16.378Z", + "checks": [ + { + "view": "agreements", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "calendar", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "imports", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "alternatives", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "reservations", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "deliveries", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "suggestions", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "history", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "lots", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "dispositions", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + }, + { + "view": "reversals", + "status": 200, + "ready": true, + "admin_has_no_automatic_write": true + } + ] +} \ No newline at end of file diff --git a/docs/proofs/supply-suggestions.json b/docs/proofs/supply-suggestions.json new file mode 100644 index 0000000..e3c4b9d --- /dev/null +++ b/docs/proofs/supply-suggestions.json @@ -0,0 +1,47 @@ +{ + "checked_at": "2026-11-01T09:35:52.5891276Z", + "scope": "preserved replenishment suggestions and single conversion to ordinary requisition draft", + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 774, + "passed": 772, + "assertions": 8009, + "duration_ms": 91002, + "skipped": 2 + }, + "postgres_output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":22,\"passed\":22,\"assertions\":197,\"duration_ms\":8986}", + "postgres_races": [ + { + "scenario": "same_reference", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "same_operation", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "single_conversion", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "conversion_retry", + "real_lock_contention": true, + "result": "passed" + } + ], + "scoped_static_analysis": "passed", + "types_and_build": "passed", + "navigation": "seven local pages passed", + "visual_review": "pending", + "persistent_business_data_or_grants_changed": false, + "source_sha256": { + "app/Services/SupplySuggestions.php": "79de82e1d55fad6a33f1b8476ed0708215effd3167944bbd9e6b535905a8c4e1", + "app/Http/Controllers/SupplyPlanningController.php": "e0209afa0bfd26cdd4a8a8d664185a9a0fdcb5f0b0dbbec31df01129aff60409", + "resources/js/pages/supply/suggestions.tsx": "19ca343e4e5d29ce1054125bcac78947b48c11be57123b188002d5f612b7d398", + "tests/Feature/SupplySuggestionsTest.php": "698334546b12557b4889982240569f6440e9bcd0265906b1124f0c4c479ea61d" + } +} diff --git a/docs/proofs/warehouse-availability.json b/docs/proofs/warehouse-availability.json new file mode 100644 index 0000000..6dae964 --- /dev/null +++ b/docs/proofs/warehouse-availability.json @@ -0,0 +1,22 @@ +{ + "checked_at": "2026-11-01T15:37:12.7410723Z", + "tests": { + "tool": "pest", + "result": "passed", + "tests": 16, + "passed": 16, + "assertions": 123, + "duration_ms": 2604 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "automatic_purchase_generation_implemented": false, + "physical_reservation_implemented": false, + "visual_browser_review": false, + "grants_changed": false +} diff --git a/docs/proofs/warehouse-drafts-install.json b/docs/proofs/warehouse-drafts-install.json new file mode 100644 index 0000000..96690c0 --- /dev/null +++ b/docs/proofs/warehouse-drafts-install.json @@ -0,0 +1,14 @@ +{ + "checked_at": "2026-11-01T16:11:06+00:00", + "migration": "2026_11_01_000047_create_warehouse_requests", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-161103\/before.dump", + "backup_sha256": "cc7a2be9c1ffd1ace78cdfb84816c359845b75808614f2323e77c053ce8cfe4c", + "restore_verified": true, + "existing_tables_preserved": 107, + "new_tables": { + "warehouse_requests": 0 + }, + "accounts_or_grants_changed": false, + "domain_installed": false +} \ No newline at end of file diff --git a/docs/proofs/warehouse-drafts-pg.json b/docs/proofs/warehouse-drafts-pg.json new file mode 100644 index 0000000..b4ba55c --- /dev/null +++ b/docs/proofs/warehouse-drafts-pg.json @@ -0,0 +1,19 @@ +{ + "checked_at": "2026-11-01T16:29:30+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":12,\"passed\":12,\"assertions\":122,\"duration_ms\":6270}", + "races": [ + { + "scenario": "same_operation", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "different_operations", + "real_lock_contention": true, + "result": "passed" + } + ], + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/warehouse-drafts.json b/docs/proofs/warehouse-drafts.json new file mode 100644 index 0000000..9b02a4d --- /dev/null +++ b/docs/proofs/warehouse-drafts.json @@ -0,0 +1,33 @@ +{ + "checked_at": "2026-11-01T16:12:53.4644888Z", + "regression": { + "tool": "pest", + "result": "passed", + "tests": 81, + "passed": 81, + "assertions": 809, + "duration_ms": 9360 + }, + "postgresql": { + "tool": "pest", + "result": "passed", + "tests": 6, + "passed": 6, + "assertions": 66, + "duration_ms": 3778 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "install_proof": "warehouse-drafts-install.json", + "http": "installed page and no automatic admin preparation grant verified", + "sales_invoice_link_implemented": false, + "physical_reservation_implemented": false, + "automatic_purchase_generation_implemented": false, + "visual_browser_review": false, + "accounts_or_grants_changed": false +} diff --git a/docs/proofs/warehouse-fulfilment-install.json b/docs/proofs/warehouse-fulfilment-install.json new file mode 100644 index 0000000..bbbb5b6 --- /dev/null +++ b/docs/proofs/warehouse-fulfilment-install.json @@ -0,0 +1,14 @@ +{ + "checked_at": "2026-11-01T16:29:58+00:00", + "migration": "2026_11_01_000048_create_warehouse_request_events", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-162956\/before.dump", + "backup_sha256": "15c5856c46472b22e05bcf3a1514184d75ebdf4101af46ef16d115b091a39741", + "restore_verified": true, + "existing_tables_preserved": 108, + "new_tables": { + "warehouse_request_events": 0 + }, + "accounts_or_grants_changed": false, + "domain_installed": false +} \ No newline at end of file diff --git a/docs/proofs/warehouse-fulfilment.json b/docs/proofs/warehouse-fulfilment.json new file mode 100644 index 0000000..1e5b415 --- /dev/null +++ b/docs/proofs/warehouse-fulfilment.json @@ -0,0 +1,52 @@ +{ + "checked_at": "2026-11-01T16:35:27.9730273Z", + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 908, + "passed": 906, + "assertions": 9508, + "duration_ms": 118323, + "skipped": 2 + }, + "directed": { + "tool": "pest", + "result": "passed", + "tests": 85, + "passed": 85, + "assertions": 495, + "duration_ms": 7284 + }, + "postgresql": { + "checked_at": "2026-11-01T18:29:30+02:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":12,\"passed\":12,\"assertions\":122,\"duration_ms\":6270}", + "races": [ + { + "scenario": "same_operation", + "real_lock_contention": true, + "result": "passed" + }, + { + "scenario": "different_operations", + "real_lock_contention": true, + "result": "passed" + } + ], + "persistent_data_changed": false, + "database_removed_in_finally": true + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "installation_proof": "warehouse-fulfilment-install.json", + "invoice_required": false, + "physical_exclusive_reservation_implemented": false, + "automatic_purchase_generation_implemented": false, + "visual_browser_review": false, + "accounts_or_grants_changed": false +} diff --git a/docs/proofs/work-context-http.json b/docs/proofs/work-context-http.json new file mode 100644 index 0000000..c019cb1 --- /dev/null +++ b/docs/proofs/work-context-http.json @@ -0,0 +1,30 @@ +{ + "checked_at": "2026-11-01T12:14:32.960Z", + "environment": "http://127.0.0.1:8005", + "evaluation_only": true, + "results": [ + { + "option": "procurement.internal", + "access": 200, + "other_area": 403 + }, + { + "option": "people.internal", + "access": 200, + "other_area": 403 + }, + { + "option": "procurement.sales", + "access": 409, + "other_area": 403 + }, + { + "option": "people.third_party", + "access": 409, + "other_area": 403 + } + ], + "created_accounts": false, + "business_commands": false, + "visual_verified": false +} diff --git a/docs/proofs/work-context-install.json b/docs/proofs/work-context-install.json new file mode 100644 index 0000000..6658049 --- /dev/null +++ b/docs/proofs/work-context-install.json @@ -0,0 +1,14 @@ +{ + "checked_at": "2026-11-01T12:07:37+00:00", + "migration": "2026_11_01_000045_add_work_context_configuration", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-120734\/before.dump", + "backup_sha256": "4fd480bcfb8ec73d551073e1fa606a2f7e5d78d7e418c9546ba481d528891d21", + "restore_verified": true, + "existing_tables_preserved": 106, + "new_tables": [], + "accounts_or_grants_changed": false, + "domain_installed": false, + "existing_processes_classified_internal": true, + "commercial_policy_activated": false +} \ No newline at end of file diff --git a/docs/proofs/work-context-pg.json b/docs/proofs/work-context-pg.json new file mode 100644 index 0000000..91ecdb5 --- /dev/null +++ b/docs/proofs/work-context-pg.json @@ -0,0 +1,7 @@ +{ + "checked_at": "2026-11-01T12:15:11+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":12,\"passed\":12,\"assertions\":83,\"duration_ms\":6876}", + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/work-context.json b/docs/proofs/work-context.json new file mode 100644 index 0000000..08b500a --- /dev/null +++ b/docs/proofs/work-context.json @@ -0,0 +1,36 @@ +{ + "checked_at": "2026-11-01T12:15:39.6829015Z", + "increment": "work context foundation", + "general_regression": { + "assertions": 8503, + "skipped": 2, + "tests": 860, + "passed": 858 + }, + "final_targeted": { + "assertions": 644, + "tests": 66, + "passed": 66 + }, + "postgres_disposable": { + "assertions": 83, + "tests": 12, + "passed": 12 + }, + "directed_phpstan": "passed", + "typescript": "passed", + "directed_format_and_lint": "passed", + "build": "passed", + "local_http": "work-context-http.json", + "local_migration": "work-context-install.json", + "visual_verified": false, + "browser_limitation": "browser access host is unavailable", + "pending": [ + "third party operational workflows", + "all form/history switching coverage", + "commercial module catalogue UI", + "full update/distribution tooling", + "legal contract formalization and operational RH" + ], + "production_installed": false +} \ No newline at end of file diff --git a/docs/proofs/workflow-notices-install.json b/docs/proofs/workflow-notices-install.json new file mode 100644 index 0000000..9ad8f24 --- /dev/null +++ b/docs/proofs/workflow-notices-install.json @@ -0,0 +1,14 @@ +{ + "checked_at": "2026-11-01T15:06:34+00:00", + "migration": "2026_11_01_000046_create_workflow_notices", + "environment": "local evaluation 8005", + "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-150631\/before.dump", + "backup_sha256": "947a8ce2724f31c10ce980fdeb4bba5bf247e0178e2c0fb137509713d8d4ad05", + "restore_verified": true, + "existing_tables_preserved": 106, + "new_tables": { + "workflow_notices": 0 + }, + "accounts_or_grants_changed": false, + "domain_installed": false +} \ No newline at end of file diff --git a/docs/proofs/workflow-notices-pg.json b/docs/proofs/workflow-notices-pg.json new file mode 100644 index 0000000..bb2f98e --- /dev/null +++ b/docs/proofs/workflow-notices-pg.json @@ -0,0 +1,7 @@ +{ + "checked_at": "2026-11-01T15:25:02+00:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":26,\"passed\":26,\"assertions\":391,\"duration_ms\":16338}", + "persistent_data_changed": false, + "database_removed_in_finally": true +} \ No newline at end of file diff --git a/docs/proofs/workflow-notices.json b/docs/proofs/workflow-notices.json new file mode 100644 index 0000000..e233ddc --- /dev/null +++ b/docs/proofs/workflow-notices.json @@ -0,0 +1,40 @@ +{ + "checked_at": "2026-11-01T15:11:34.1739866Z", + "full_suite": { + "tool": "pest", + "result": "passed", + "tests": 891, + "passed": 889, + "assertions": 9236, + "duration_ms": 122924, + "skipped": 2 + }, + "directed": { + "tool": "pest", + "result": "passed", + "tests": 42, + "passed": 42, + "assertions": 455, + "duration_ms": 5320 + }, + "postgresql": { + "checked_at": "2026-11-01T17:11:01+02:00", + "database": "disposable PostgreSQL", + "result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":21,\"passed\":21,\"assertions\":244,\"duration_ms\":9906}", + "persistent_data_changed": false, + "database_removed_in_finally": true + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "installation_proof": "workflow-notices-install.json", + "http_proof": "approval-routing-http.json", + "external_messages_sent": false, + "existing_accounts_or_grants_changed": false, + "visual_browser_review": false, + "retroactive_notices_created": false +} diff --git a/docs/proofs/workflow-outcomes.json b/docs/proofs/workflow-outcomes.json new file mode 100644 index 0000000..4faafda --- /dev/null +++ b/docs/proofs/workflow-outcomes.json @@ -0,0 +1,32 @@ +{ + "checked_at": "2026-11-01T15:21:03.6176610Z", + "directed": { + "tool": "pest", + "result": "passed", + "tests": 45, + "passed": 45, + "assertions": 573, + "duration_ms": 6131 + }, + "postgresql": { + "tool": "pest", + "result": "passed", + "tests": 24, + "passed": 24, + "assertions": 362, + "duration_ms": 11775 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "runtime_files_checked": 18, + "http": "tasks outcomes unread views passed", + "migration_required": false, + "external_messages_sent": false, + "visual_browser_review": false, + "grants_changed": false +} diff --git a/docs/proofs/workflow-routing-refresh.json b/docs/proofs/workflow-routing-refresh.json new file mode 100644 index 0000000..3a97c61 --- /dev/null +++ b/docs/proofs/workflow-routing-refresh.json @@ -0,0 +1,32 @@ +{ + "checked_at": "2026-11-01T15:25:46.4380373Z", + "directed": { + "tool": "pest", + "result": "passed", + "tests": 47, + "passed": 47, + "assertions": 602, + "duration_ms": 7006 + }, + "postgresql": { + "tool": "pest", + "result": "passed", + "tests": 26, + "passed": 26, + "assertions": 391, + "duration_ms": 16338 + }, + "analysis": { + "tool": "phpstan", + "result": "passed", + "errors": 0 + }, + "types": "passed", + "build": "passed", + "runtime_files_checked": 18, + "migration_required": false, + "external_messages_sent": false, + "automatic_profile_change_reconciliation": false, + "visual_browser_review": false, + "existing_business_data_or_grants_changed": false +} diff --git a/docs/transferencia-sgf.md b/docs/transferencia-sgf.md new file mode 100644 index 0000000..898440e --- /dev/null +++ b/docs/transferencia-sgf.md @@ -0,0 +1,232 @@ +# Transferência de Compras e actualizações do SGF + +## Objectivo e estado em 01/11/2026 + +O foco autorizado pelo proprietário é transferir as capacidades de Compras para o SGF e levar as melhorias seguintes por versões verificáveis. Não é sincronizar bases de desenvolvimento nem manter duas implementações. O SGC autónomo conserva todas as suas funcionalidades; no SGF, Pessoas e contratações destina-se apenas ao uso interno. Cada cliente recebe as capacidades adquiridas, com configuração e permissões próprias. + +**Implementado neste incremento:** mecanismo de publicação/instalação/actualização/reversão e primeira capacidade realmente extraída e executada no SGF: projecção de reposição de stock. **A transferência integral dos processos, ecrãs, identidade e dados de negócio ainda não está concluída.** + +**Conferência antes de ampliar, 01/11/2026:** a prioridade passa a ser esta matriz e a delimitação de instalação/reversão, conforme pedido posterior do proprietário. SGF local activo em `0.1.1`. O núcleo económico começou a ser preparado antes desse pedido; a transferência em curso foi interrompida antes de nova activação. Existe candidata `0.1.2` no canal, sem instalação no SGF. Código preparado na origem não conta como capacidade transferida. + +**Actualização posterior autorizada:** percurso de requisição interna implementado como candidata de revisão `IR-CANDIDATE-bfa807e2b3c4fae7`, testada sem activar o destino. A matriz abaixo distingue essa implementação candidata da capacidade instalada. Ver detalhes, recuperação e limites na secção do percurso escolhido. + +## Fonte única e fronteira dos dois hosts + +`packages/purchasing` em Compras é a fonte canónica. `App\Services\ReplenishmentProjection` passou a fachada de compatibilidade; os consumidores actuais conservam a assinatura e usam o motor comum `Tempo\Purchasing\Supply\ReplenishmentProjection`. O SGF carrega os mesmos bytes de uma pasta de versão instalada. Não há chamada HTTP a Compras, procura da pasta vizinha, segundo algoritmo ou alteração das dependências instaladas. + +As diferenças concretas que impedem uma cópia directa da aplicação permanecem: SGF usa tenant/empresa fiscal/unidade e concessões/contexto próprios, enquanto Compras usa tenant/departamento e perfis; ambos têm classes `App` e tabelas-base com significados diferentes. Os frontends e versões de PHP/Brick Math também diferem. Não copiar os controladores, tabelas `users`/`tenants` ou histórias de migração de um host para o outro. Extrair capacidades e adaptar contexto/acesso explicitamente. + +## Matriz de integração conferida + +Estados: **Transferido** identifica a capacidade exacta instalada e executada no SGF; **Parcialmente transferido** identifica componentes comuns existentes, com lacunas no percurso; **Pendente** identifica capacidades ainda não incorporadas no SGF. A presença de um menu, cálculo ou classe não comprova transferência do processo completo. Capacidades excluídas do SGF são identificadas à parte. + +| Processo/capacidade | Estado | Núcleo e persistência | Adaptadores de identidade/acesso e integração | Ecrãs e percurso no SGF | Evidência/lacuna determinante | +| --- | --- | --- | --- | --- | --- | +| Publicação, instalação e actualização de código do pacote | **Transferido** | Artefactos imutáveis, hashes, lock, histórico e ponteiro local; sem schema | CLI local e prova com PHP do host; sem alteração de direitos | CLI implementada; painel de updates não implementado | `0.1.0/0.1.1`, retry e rollback de código conferidos; migrações e canal remoto assinado pendentes | +| Cálculo de projecção de reposição | **Transferido** | Mesmo motor de consumo manual, média racional, reservas/entregas e lotes; sem persistência própria | Carregamento local independente do checkout de Compras; não concede acesso a dados | Sem novo ecrã; capacidade de cálculo instalada | Pacote activo `0.1.1`, dois testes SGF e prova de cliente independente | +| Orçamento de Compras no núcleo comum | **Parcialmente transferido** | `packages/budget-control` comum anterior; mesmo motor de reserva | Composição operacional candidata com destino/schema/âmbito explícitos; adaptador instalado continua QA | Interface anterior e candidata de requisições; arranque/rotas operacionais ainda não integrados | 20 controlos novos de preparação; restauro em production/base fora do prefixo QA verificado; sem instalação/activação no destino | +| Preparação de schema da integração | **Parcialmente transferido — candidata não instalada** | Migração transaccional, contrato do orçamento, registo imutável de revisão/hashes/operadores/backup | CLI `plan`/`migrate` local ao host, sem cópia de dados sintéticos ou concessões | Sem ecrã ou activação implícita; CLI de preparação não substitui instalação de código/rotas | Prova nova `sgf-operational-delivery.json`; destino 0.1.1 preservado | +| Factura conferida → obrigação a fornecedor | **Parcialmente transferido** | Núcleo comum de obrigações anterior, com origem única | Adaptador SGF e API no âmbito sintético; sem passagem automática de tabelas/IDs de Compras | Consulta/decisão do percurso anterior; sem workflow integral de recepção/conferência de facturas de Compras | `SupplierPayables` conserva guarda de ensaio; fornecedor de histórico SGF não concluído conforme entregas anteriores | +| Pagamentos e afectações a fornecedores | **Parcialmente transferido** | Núcleo comum e entregas anteriores preservados | Adaptadores/autorizações delimitados nas respectivas entregas; não representam migração de contas ou histórico de cliente | Percurso anterior existe no âmbito documentado; integração completa com novas origens de Compras pendente | `SUPPLIER_SETTLEMENT_DELIVERY.md` e limites de histórico/origens; não afirmar financeiro integral | +| Identidade, empresa/unidade, perfis e módulos adquiridos | **Parcialmente transferido** | Fundações próprias existentes nos dois hosts; não se fundiram tabelas-base | SGF mantém tenant/empresa fiscal/unidade/concessões; correspondência explícita com o contexto Purchasing por concluir | Sessão/contexto SGF existentes; selector SGC não foi incorporado automaticamente | Sem associação por igualdade de nome, email ou ID; parametrização comercial e permissões continuam distintas | +| Reorganização modular do SGF pedida posteriormente | **Pendente — sem resultado apresentado** | Entrega distinta da integração candidata de requisições | Não avaliada nem concluída por esta conferência | Não inferir reorganização entregue a partir dos ecrãs candidatos | Exige apresentação própria do âmbito, alterações e resultado; não executada por arrasto | +| Requisição interna: rascunho, submissão, aprovação independente e consulta | **Parcialmente transferido — candidata; não activa** | Motor único `InternalRequisitions`, adaptadores dos dois hosts; recibos, versões e histórico; reserva existente na mesma transacção | Contexto interno, empresa/unidade, autorização renovada, identidade independente e fontes explicitamente associadas; sem novas concessões no destino | Rascunho/edição, lista, submissão, decisão, histórico e parametrização na candidata; sem novo menu/rota activa no SGF | HTTP, concorrência, revogação, falha/recuperação e schema em bases isoladas; equipamentos/peças e instalação comercial pendentes; destino 0.1.1 | +| Planeamento de compras | **Pendente** | Itens, marcos e associação a requisições permanecem em Compras | Adaptar unidades, centros, intervenientes e vínculos de origem no SGF | Planeamento e acompanhamento de marcos SGF do roteiro de Compras pendentes | Não gerar requisições duplicadas nem inferir reserva/decisão a partir de um plano | +| Fornecedores, documentos e dados bancários | **Pendente** | Cadastros/versionamento de Compras ainda não extraídos | Definir correspondências autorizadas de fontes e permissões, sem fundir entidades por nome | Cadastro, validações e consulta SGF de todo o percurso pendentes | Preservar fotografias utilizadas e validação bancária independente | +| Cotações, descontos e comparação económica | **Pendente** | Descontos e pontuação foram preparados na origem/candidata `0.1.2`; SGF activo não contém estas classes | Elegibilidade, conflitos, avaliações autorizadas e decisão continuam no host; adaptador do processo SGF não implementado | Recepção de cotações, grelha, empates, recomendação e decisão SGF pendentes | Testes locais não equivalem a transferência. Candidata não instalada; nenhuma adjudicação automática | +| Abertura, admissibilidade, qualificação, recomendação e adjudicação | **Pendente** | Serviços/snapshots permanecem em Compras | Traduzir contexto, atribuições, impedimentos e versões sem alterar decisões concluídas | Actas, avaliações, decisões e ordens ainda não incorporadas | Necessita persistência e percurso completo por etapa, não simples ligação de menus | +| Ordens, recepções, inventário físico e lotes | **Pendente** | O cálculo de reposição está transferido; movimentos, ledger físico, decisões e reversões não | Adaptar origens únicas, armazéns/artigos, separação de funções e âmbito empresarial | Guias, recepções, saídas, transferências, contagens e lotes SGF de Compras pendentes | Não duplicar efeitos físicos ou confundir projecção com stock disponível | +| Fornecimento habitual, sugestões e importações | **Parcialmente transferido** | Apenas o motor de projecção incorporado; acordos, políticas versionadas, conversões e logística pendentes | Fontes, decisões, cadastros e vínculos aos processos SGF ainda por adaptar | Calendário, sugestão → requisição e importação operacional SGF pendentes | Um cálculo disponível não completa o workflow de abastecimento | +| Equipamentos, peças, manutenção e investimento | **Pendente** | Serviços/cadastros de Compras ainda não incorporados | Correspondência de activos/peças/unidades e regras aprovadas por definir tecnicamente | Ecrãs/percursos de Compras no SGF pendentes | Preservar origens e não repetir consumo físico/custo | +| Avaliação de fornecedores, melhoria e contraditório | **Pendente** | Versionamento, decisões e evidências permanecem em Compras | Adaptadores de intervenientes, conflitos e autorização independente pendentes | Preparação, revisão, acompanhamento e relatórios SGF pendentes | Não alterar avaliações antigas nem confundir qualificação com desempenho | +| Relatórios de Compras e sínteses factuais | **Pendente** | Projecções autorizadas de Compras ainda não extraídas | Reutilizar âmbito efectivo e versões do processo no SGF | Relatórios individuais/consolidados do roteiro de Compras pendentes | Não substituir por relatórios financeiros SGF de outro âmbito; núcleo factual independente de IA | +| Venda de mercadorias e ligação ao comercial SGF | **Pendente** | A opção de contexto não é um percurso completo de retalho/grosso demonstrado em Compras | Reutilizar responsabilidades de stock/facturação SGF, sem criar motores ou efeitos concorrentes | Percurso conjunto por módulos adquiridos ainda por demonstrar | Não confundir aquisição para revenda com venda efectuada nem declarar emissão fiscal transferida | +| Pessoas: recrutamento e prestadores internos | **Pendente** | Processos e versões ainda em Compras; fronteira técnica de RH apenas preparada | Adaptar perfis/atribuições e integração futura com RH; apenas contrato formalizado pode desencadear ligação | Candidaturas, avaliações, decisões e formalização/RH no SGF pendentes | RH ainda não implementado; selecção aprovada/rascunho não criam vínculo | + +**Fora do âmbito SGF:** recrutamento e contratação de prestadores por conta de terceiros. Mantêm-se no SGC autónomo; não são uma pendência a implementar no SGF. Não transferir candidaturas automaticamente para Compras, vendas ou RH. + +### Percurso escolhido e candidata de requisições internas + +O proprietário autorizou **requisição interna → submissão → aprovação independente → consulta do processo**, usando a reserva orçamental existente. A implementação está preparada separadamente para revisão. **O SGF continua em 0.1.1; nem a candidata económica 0.1.2 nem esta integração foram activadas.** Não foram publicados pacotes nem alterados direitos públicos. + +O motor `packages/purchasing/src/InternalRequisitions.php` é usado pelo adaptador autónomo `app/Services/InternalRequisitionPort.php` e pela candidata `integrations/sgf/internal-requisitions`. Conserva as regras de valores, limites, reserva antes da submissão e decisão por identidade diferente do requerente e dos decisores anteriores. O adaptador SGF reutiliza `CompanyContext`, `Permissions` e `BudgetAdapter`; não copia utilizadores, empresas ou o motor orçamental. + +A candidata inclui rascunho/edição, listagem, submissão, decisão e consulta com histórico, além de parametrização versionada de centros/departamentos/intervenientes/limites. Apenas centros e pessoas autorizados do host podem ser associados. A configuração habilita a opção interna; não atribui permissões. O servidor volta a validar direitos e contexto em cada operação, incluindo repetições, e projecta as acções disponíveis para os ecrãs. Chave de operação, hash do conteúdo, versão esperada, locks e transacção única preservam origem e impedem duplicação de requisições/reservas em retry e concorrência. Uma falha mantém a transacção anterior; uma resposta perdida admite a repetição da mesma operação. + +Os artefactos de revisão são produzidos por `php scripts/build-internal-requisition-candidate.php`. O manifesto, hashes e caminhos estão em [proofs/internal-requisitions-candidate.json](proofs/internal-requisitions-candidate.json). `runtime.zip` contém código e schema explicitamente não instaláveis pelo updater actual; `verification.zip` contém as provas. Não levam bases, dados sintéticos, credenciais, dependências ou concessões. O caminho do SGF serve apenas para preparar/verificar; o runtime candidato utiliza ficheiros locais e serviços do próprio host. A futura instalação exige uma entrega compatível com schema, separada da activação do ponteiro de código. + +**Limitações da candidata:** SGF suporta neste recorte consumíveis, serviços e outros; equipamentos/peças e respectivas validações técnicas continuam apenas no percurso autónomo. Fornecedores, cotações, ordens e recepções não foram incorporados por arrasto. As rotas/ecrãs só são carregados pelo bootstrap explícito das provas; não há novo menu activo no destino. A integração do catálogo privado de permissões na administração comercial, a instalação/migração operacional e a parametrização comercial integral permanecem pendentes. A guarda isolada do adaptador orçamental SGF foi preservada. Não há integração de contratações para terceiros nem activação de RH. + +### Classificação das pendências para decidir a instalação + +**Incremento posterior de preparação dos acessos:** o catálogo e a autoridade inicial já têm implementação candidata em `integrations/sgf/access`, preparada separadamente em `ACCESS-CANDIDATE-a69f06e21747228a`. Prova [proofs/sgf-access-delivery.json](proofs/sgf-access-delivery.json): **24 controlos novos**, em PostgreSQL descartável, sem repetir as suites aprovadas da requisição. O SGF instalado continua com o catálogo anterior. As classificações abaixo mantêm-se bloqueantes **até integrar e instalar** esta preparação com o orçamento/schema/arranque operacionais; não significam ausência de implementação candidata. + +Conferência de código posterior à entrega, sem repetir testes aprovados e sem alterar a candidata congelada ou a instalação SGF. **A candidata comprova o percurso em ensaio isolado; ainda não é uma entrega operacional instalável.** + +| Pendência | Classificação para este percurso | Impacto e condição de resolução | +| --- | --- | --- | +| Equipamentos/peças e validações técnicas | **Fora do âmbito** do percurso básico entregue | O adaptador SGF aceita `consumable`, `service` e `other`. Estas requisições podem completar rascunho → submissão/reserva → aprovação → consulta sem equipamentos/peças. Uma necessidade de equipamento/peça continua sem percurso SGF suportado; não deve ser disfarçada como `other`. A classificação não declara suporte a todos os tipos de requisição. | +| Catálogo administrativo das novas permissões e sua gestão | **Bloqueante para este percurso em utilização operacional** | `Permissions::UNIT` contém apenas `unit.view` e `unit.edit`; `IdentityWorkflow::accessList/changeAccess` filtra/valida esse catálogo. Os direitos `purchasing_requisitions.*` não podem ser atribuídos/revogados pela administração normal. As concessões directas dos testes foram apenas sintéticas e não constituem configuração comercial entregue. | +| Autoridade inicial para gerir/outorgar os novos direitos | **Bloqueante para este percurso em utilização operacional** | `changeAccess` exige que quem concede já possua o direito concedido. Acrescentar nomes ao catálogo não resolve a atribuição inicial: é necessária entrega nominativa, explícita e auditada da autoridade aprovada pela empresa, sem outorga automática aos perfis actuais. A listagem e revogação de direitos derivados da unidade também devem abranger o catálogo integrado. | +| Adaptador orçamental operacional | **Bloqueante para este percurso em utilização operacional** | `BudgetAdapter::guard()` exige ambiente `budget-testing`, PostgreSQL local e base `budget_core_sgf_<12hex>`. O adaptador de requisições chama essa guarda inclusive na consulta/configuração. Não basta copiar ficheiros ou permitir requisições sem orçamento. Substituir a composição exclusiva de ensaio por composição operacional configurada, mantendo autorização, isolamento e motor comum; não renomear base real nem mudar o ambiente para contornar a guarda. Os direitos orçamentais necessários também não integram o catálogo administrativo actual. | +| Instalação/migração, arranque e encaminhamento das rotas | **Bloqueante para este percurso em utilização operacional** | `Schema::install` e bootstrap existem, mas só foram compostos no harness isolado. Falta instalador operacional com pré-condições, registo de migração e recuperação, composição local e registo controlado de rotas. O updater actual recusa esta candidata; não é possível instalar o percurso com o comando habitual de update de código. | +| Fornecedores, cotações, ordens, recepções e contratações para terceiros | **Fora do âmbito** desta entrega | Não são necessários para terminar a requisição no estado aprovado e consultá-la; contratação para terceiros permanece excluída do SGF. | + +**Direitos que a instalação deve permitir gerir, sem os conceder implicitamente:** por empresa/unidade, `purchasing_requisitions.view`, `.prepare`, `.configure`, `.approve.department_head`, `.approve.procurement` e `.approve.finance`. Mantêm-se `company.view`, pertença à empresa e `unit.view` como condições do contexto. Submeter com reserva exige ainda `purchasing_budget.record`; rejeitar com libertação também usa esse direito existente. Preparação/aprovação da dotação usa os direitos do orçamento existente e deve ter responsáveis expressamente configurados. Só os escalões aplicáveis à política precisam de decisores, sempre com identidades distintas do requerente e dos decisores anteriores. Não é necessário conceder todos os direitos a todos os utilizadores. + +#### Implementação candidata do catálogo e autoridade inicial + +`php scripts/prepare-sgf-access.php ` lê os dois ficheiros de identidade do host e produz uma sobreposição de revisão em `storage/app/private/candidates/ACCESS-CANDIDATE-`, com manifesto e hashes antes/depois. **Não escreve na pasta SGF**, não aplica schema nem concede direitos. Recusa preparar uma alteração se os pontos esperados no host divergirem, exigindo reconciliação do trabalho em curso. A candidata original `IR-CANDIDATE-bfa807e2b3c4fae7` não foi reescrita. + +Na sobreposição, o catálogo unitário inclui os direitos de requisição e orçamento existentes; a criação de unidades continua a outorgar **apenas** `unit.view`/`unit.edit`. A administração nativa passa a listar/atribuir/revogar os novos direitos, conservando a exigência de o administrador possuir o direito que outorga. A atribuição unitária exige consulta da unidade pelo destinatário. Revogar a consulta da unidade confere autoridade sobre todos os direitos derivados afectados antes de os remover numa transacção; uma recusa não elimina parcialmente acessos. + +`InitialAuthority.php` resolve a atribuição inicial como operação explícita. Exige administrador autenticado com `access.manage`, consulta/pertença ao contexto e uma lista de âmbitos habilitados pela futura composição de instalação (`purchasing-access.initialisation_scopes`, chaves `tenant:company:unit`; vazia por omissão). Só admite como destinatários administradores nominativos já autorizados nessa empresa/unidade. Escolhem-se os direitos e um fundamento, sem selecção automática de todos os direitos. Não atribui perfis de desenvolvimento. A concessão e a auditoria nativa `purchasing.access_initialised` são atómicas, serializadas pela empresa. Uma falha de auditoria desfaz as concessões. Uma repetição exacta não concede de novo nem recupera direitos entretanto revogados; outra inicialização é recusada, seguindo-se a gestão normal de acessos. Revogar a autoridade administrativa bloqueia também a consulta do ecrã e a repetição da operação. + +As rotas candidatas em `integrations/sgf/access/routes.php` oferecem o ecrã `/purchasing/internal-requisitions/access-initialisation` e a operação `/api/internal-requisitions/access-initialisation`, com os middlewares nativos de autenticação, sessão e empresa. O ecrã mostra o contexto, permite escolher um destinatário nominativo e os direitos e envia CSRF/token de contexto/chave estável de operação. **Estas rotas não estão registadas no destino.** A prova nova verificou serviços nativos, persistência, autorização e renderização do ecrã; não declara ensaio HTTP completo ou revisão visual deste novo ecrã. A composição de instalação deverá ligar estas rotas ao módulo autorizado e retirar a habilitação de inicialização depois da entrega, preservando a auditoria. + +Próxima dependência concreta: preparar a composição orçamental operacional e o instalador rastreável, incorporar esta sobreposição ao pacote SGF específico e conferir a sua integração HTTP. Não activar o módulo, a candidata de acessos ou 0.1.2 por consequência desta implementação. + +### Procedimento de instalação: estado exacto e sequência obrigatória + +**Incremento posterior — composição orçamental e preparação de schema:** implementados em candidata separada `integrations/sgf/operational` e `scripts/sgf-internal-install.php`. Já existe executor de **preparação de schema**, com `plan` e `migrate`; continua sem existir comando completo de instalação/activação de código e rotas. Esta actualização sucede à ausência de executor registada na primeira conferência, preservando o âmbito das provas anteriores. Ver [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json). Nenhum destes comandos foi executado na base da instalação SGF de destino. + +`php scripts/prepare-sgf-operational.php ` lê o host e produz `OPERATIONAL-CANDIDATE-`, com manifesto, sobreposições de identidade/orçamento e ficheiros de suporte locais. Não escreve no SGF nem publica um pacote. A sobreposição orçamental conserva o motor e autorizações existentes; acrescenta composição operacional habilitada explicitamente, sem depender de `APP_ENV=budget-testing` ou de um nome de base QA. A guarda exige impressão do destino real (configuração e endereço/porta PostgreSQL efectivos), schema/versão de migração conferidos e âmbito `tenant:company:unit` autorizado em `purchasing-operational.scopes`. `purchasing-operational.enabled` é falso por omissão e `purchasing-operational.target` deve coincidir com o destino conferido. A guarda de ensaio anterior permanece para a composição QA, sem se tornar uma autorização operacional alternativa. + +A preparação exige fundação de identidade existente; cria o schema orçamental vazio se ausente, ou confere o schema completo existente contra o contrato estrutural aceite, conservando registos e dotação aprovada. Schema parcial ou incompatível é recusado. Estende os valores unitários aceites pelo constraint de permissões sem conceder direitos. Aplica o schema de requisições e grava `sgc_installations` imutável, revisão 1, assinatura estrutural, hash da migração, backup, operador nominativo de implantação e role PostgreSQL executante. Usa lock transaccional de instalação e DDL transaccional. Repetição com a mesma migração/schema reconhece a preparação anterior; alterações divergentes são recusadas sem sobrescrever. **Não há `down` nem eliminação de dados no executor.** A revisão actual não é mecanismo genérico para futuras migrações; estas exigem novo plano compatível e revisão própria. + +Procedimento da CLI **na candidata**, pelo PHP fixado do host, apenas depois da decisão de instalação e preservação do destino: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-internal-install.php" plan "" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/scripts/sgf.ps1" php "/scripts/sgf-internal-install.php" migrate "" "" "" +``` + +`plan` só lê/verifica: integridade dos ficheiros da candidata, compatibilidade dos ficheiros do host com hashes antes/depois, identidade do destino, hash do manifesto e existência do registo de preparação. O campo `schema_prepared` informa a existência da tabela de registo; não substitui a conferência integral feita por `migrate`/guarda operacional. Conferir o manifesto/hashes com a referência de entrega por canal de confiança. `migrate` exige correspondência do destino e do manifesto, ficheiro de backup existente e SHA-256 coincidente; lê o índice com `pg_restore --list` antes de escrever. O cliente PostgreSQL deve estar no PATH ou indicado por `PG_RESTORE_BINARY`. Conferir o índice não prova recuperação: manter o restauro/reconciliação já aceites e o ensaio adicional da preparação operacional registado na prova. Nenhuma credencial deve ser passada nos argumentos ou copiada para o manifesto; usa-se a ligação configurada do próprio host. + +**Prova deste incremento:** `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`, 20 controlos novos aprovados, incluindo CLI real, backup custom válido, recusa de backup não conferido, schema vazio/existente/parcial/incompatível, falha injectada, recuperação da transacção, repetição sem novo registo, ausência de concessões e preservação do orçamento aprovado. Após backup/restauro para outra base descartável fora de `budget_core_sgf_*`, a mesma composição passou em `production`, conferindo schema/registo de preparação, reserva existente e revogação preservada. Este ensaio verifica o novo registo/schema operacional; não repete nem substitui as suites já aprovadas da requisição. As duas bases e role restrita foram removidas; o backup sintético não integra a entrega. Os caminhos e resultados estão em [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json). Os ficheiros de código/ponteiro e dados do SGF instalado não foram alterados. + +Após `migrate`, o estado é **preparado, não activado**: não altera o ponteiro 0.1.1, providers/rotas, concessões, dados de demonstração ou configuração de activação. O código/sobreposições candidatos ainda precisam de aplicação controlada, composição de arranque e integração HTTP antes de propor a activação. A configuração comercial por empresa/unidade e a atribuição explícita de acessos continuam obrigatórias. Isto não entrega fornecedores, consumo de facturas, pagamentos, equipamentos/peças ou reorganização modular do SGF. + +**Não há neste momento um comando operacional completo para instalar esta candidata.** Os passos abaixo distinguem o que existe do que falta implementar; não são autorização para os executar no destino. A decisão de instalação continua separada. Não usar `purchasing:transfer`, `purchasing-release.php install`, os scripts de prova ou as fixtures para instalar esta integração numa empresa. O pacote `runtime.zip` é um artefacto de revisão, não deve ser extraído sobre a raiz do SGF: inclui também adaptações `app/*` de Compras que não pertencem ao host SGF. + +1. **Identificar e conferir — disponível:** fixar a candidata `IR-CANDIDATE-bfa807e2b3c4fae7`, o manifesto e os hashes em `proofs/internal-requisitions-candidate.json`; conferir o ZIP e cada ficheiro contra o manifesto. Registar raiz/destino, PHP do wrapper SGF, versão de aplicação/dependências, servidor/base/empresa/unidade e estado do ponteiro `packages/purchasing/current.json` (actualmente 0.1.1). Confirmar que os pré-requisitos de identidade e orçamento correspondem à composição pretendida. Nenhum comando desta etapa migra ou concede direitos. +2. **Preparar a entrega operacional — bloqueado pelas implementações identificadas acima:** produzir pacote SGF separado e versionado apenas com os ficheiros comuns necessários e adaptadores/ecrãs do host; incluir migração rastreável, composição operacional do orçamento, catálogo/gestão dos direitos e mecanismo de autoridade inicial. Conservar os hashes da candidata como referência; uma alteração de código origina outro artefacto, sem sobrescrever o congelado. A migração 46 de Compras não se aplica à base SGF. Não copiar tabelas base nem fixtures de Compras. +3. **Preservar o destino — procedimento necessário antes de qualquer futura escrita:** suspender tráfego de escrita e workers, aguardar transacções em curso, registar instante de corte, arquivar código/configuração de implantação e ponteiro/histórico; preservar documentos/ficheiros e contas. Fazer backup integral PostgreSQL da base conferida, não apenas das tabelas `ir_*`, pois o orçamento e as concessões também são afectados. Guardar o arquivo, hash e identidade do destino fora do pacote distribuído. Não incluir segredos no registo de implantação. +4. **Aplicar schema e preparar código sem expor rotas — executor de schema agora em candidata:** usar a CLI `plan`/`migrate` descrita acima, sem outorgar acessos. O wrapper transaccional regista revisão, confere repetições e recusa divergências; não executar `Schema::install` isoladamente. Preparar os ficheiros num directório local de versão, sem sobrepor os `app/*` de Compras nem trocar para 0.1.2. A aplicação das sobreposições e o arranque/activação do código continuam separados e pendentes. +5. **Configurar acessos e política — bloqueado pelo catálogo/autoridade inicial:** pelos mecanismos administrativos auditados, atribuir explicitamente os direitos aprovados a pessoas nominativas e ao âmbito correcto; garantir requerente e aprovadores distintos, e responsáveis da dotação. Depois configurar centros existentes, referências de departamento, intervenientes, limites, inclusão de imposto e exigência de orçamento. Nenhum nome ou ID de outra instalação é uma correspondência automática. Não inserir concessões por SQL como solução de entrega. +6. **Activar — depende de decisão separada e das etapas anteriores concluídas:** registar arranque/rotas locais do módulo e abrir o percurso apenas para contextos/módulos adquiridos e utilizadores autorizados; reiniciar serviços persistentes pelo procedimento do host. Conferir o estado aplicado, acessos efectivos e a consulta do processo no destino, sem repetir as suites já aprovadas. Registar versão, schema, configuração, autoria e instante da activação. Só então reabrir escritas/workers. Não existe nesta entrega comando que concretize esta etapa; nenhum destes passos foi executado na instalação SGF. + +### Procedimento de recuperação por tipo de falha + +Os ensaios aceites sustentam DDL transaccional e backup/restauro isolado; não equivalem a um instalador de recuperação automática de cliente. Não repetir os testes para esta classificação. + +| Situação | Acção exacta | +| --- | --- | +| Falha de preparação, antes de escrever no destino | Abandonar a preparação; conservar o código/ponteiro 0.1.1 e a base intactos. Não há migração ou reversão de dados a executar. | +| Excepção durante a transacção de schema | Fazer rollback da transacção; conferir ausência de tabelas/alterações parciais e igualdade com o inventário prévio. Se a ligação se perder durante o commit, primeiro inspeccionar schema e registo de revisão; não pressupor rollback nem repetir `Schema::install` às cegas. Manter o módulo desactivado até esclarecer o estado. | +| Código não arranca após schema confirmado, antes de novas operações | Retirar o novo registo de rotas/arranque e regressar ao código/configuração anterior arquivados; reiniciar os serviços. Manter o schema aditivo e os dados, incluindo auditoria. O ponteiro 0.1.1 não reverte alterações do host. Não executar `down`, truncar `ir_*`, estreitar a coluna de permissões ou remover direitos/histórico por arrasto. | +| Falha numa submissão/decisão ou resposta perdida | Conservar a identidade/contexto e repetir exactamente a mesma chave, versão e conteúdo; o recibo devolve o efeito já confirmado ou a transacção recomeça sem reserva duplicada. Se houver conflito de versão/conteúdo ou revogação, consultar o processo com identidade autorizada; não gerar uma nova origem para contornar o erro. | +| Corrupção/dados ou schema incompatíveis após operações novas | Suspender escritas/workers; preservar também uma cópia do estado que falhou e operações posteriores ao corte. Restaurar o backup numa **nova base vazia**, nunca por cima da base em uso. Conferir tabelas/dados, schema/constraints/triggers e consulta de origens/versões/reservas. Reconciliar explicitamente as operações posteriores ao backup e a configuração/autorização actual, incluindo revogações; um backup antigo não deve reabrir acessos revogados. Só apontar a aplicação à base recuperada após conferência e decisão registada. Conservar a base anterior para diagnóstico. | + +Forma dos comandos PostgreSQL usados na prova aceite, a adaptar ao destino conferido e à autenticação administrativa local, **não executados nesta conferência**: + +```text +pg_dump --format=custom --file= --dbname= +pg_restore --list +createdb --owner= +pg_restore --no-owner --no-privileges --exit-on-error --dbname= +``` + +O restauro da prova usou uma role restrita proprietária da nova base. `--no-owner --no-privileges` não restaura roles/grants PostgreSQL: estes devem ser preservados no inventário administrativo e repostos explicitamente no ambiente de recuperação. As permissões funcionais da aplicação são dados da base e também exigem reconciliação temporal. `pg_restore --list` só lê o índice; a prova de recuperação aceite é o restauro efectivo com reconciliação das 22 tabelas e consulta posterior já registados. Os comandos não restauram documentos externos, `.env`, dependências ou código; estes têm cópias/procedimentos próprios. Não há comando único entregue para fazer a troca operacional da base. + +### Entrega distinta da reorganização modular do SGF + +Esta entrega é **a integração candidata de requisições internas**. Extrair um motor e preparar um adaptador não comprova a reorganização modular do SGF pedida posteriormente. **Essa reorganização continua pendente, sem resultado apresentado nesta entrega**, e deve ter inventário, alterações e resultado próprios. Não a contar como concluída na matriz nem usar esta classificação como autorização para a executar por arrasto. + +### Dados/schema: ensaio separado da reversão de código + +- **Compras:** migração aditiva `2026_11_01_000046_add_requisition_command_versions.php`, sobre a base que já contém a migração 45. Acrescenta versão do processo e recibos imutáveis de comando; não reclassifica nem apaga dados. O caminho autónomo anterior continua utilizável sem instalar os recibos; o ecrã só envia chaves/versões quando o schema está preparado. `down` recusa apagar recibos já existentes. Nenhuma migração foi aplicada à base persistente de desenvolvimento. +- **SGF:** `Schema.php` acrescenta as tabelas `ir_*`, alarga o comprimento de `permission_grants.permission` e estende a condição de âmbito com os direitos privados da candidata. Conserva a condição anterior e os seus registos, sem conceder direitos. Políticas e histórico são versionados e imutáveis; snapshots submetidos e origem não são reescritos. A instalação foi ensaiada em PostgreSQL descartável já com identidades, concessões e orçamento sintéticos. +- **Falha/compatibilidade:** DDL transaccional com falha injectada preserva schema/dados anteriores e permite nova tentativa. Foi ensaiada uma evolução aditiva compatível com a candidata anterior. Isto não comprova compatibilidade com futuras alterações destrutivas. +- **Recuperação:** backup/restauro para outra base isolada, reconciliação de dados, colunas, constraints e triggers e consulta do processo restaurado. O backup de ensaio é sintético e não faz parte do pacote cliente. Uma recuperação real exige parar escritas e reconciliar operações posteriores ao backup; não há restauro automático sobre operações novas. +- **Código:** o updater continua limitado a código sem migrações. Voltar ao código 0.1.1 não remove tabelas nem restaura dados, e essa versão não apresenta o novo percurso. Não executar `down` nem recuperar uma base antiga como efeito implícito de rollback do ponteiro. + +As provas e os resultados efectivos deste incremento constam de [proofs/internal-requisitions-sgf.json](proofs/internal-requisitions-sgf.json). As provas históricas abaixo conservam o respectivo âmbito. Ecrãs foram verificados por HTTP, modelo de acções, tipos/lint e compilação; não houve revisão visual por navegador. + +| Verificação da candidata congelada | Resultado | +| --- | --- | +| Integração SGF pelo PHP 8.4.27 do host, PostgreSQL descartável | 43 controlos; 2 origens, 2 requisições e 2 reservas, incluindo save/submit/decide concorrentes em processos separados | +| Adaptador autónomo e regressão de compras/orçamento em PostgreSQL descartável | 46 testes / 339 asserções | +| Regressão completa de Compras em SQLite isolado | 883 testes: 881 aprovados, 2 ignorados; 8.950 asserções; resultado final em [proofs/internal-requisitions-validation.json](proofs/internal-requisitions-validation.json) | +| Recuperação de dados/schema | 22 tabelas reconciliadas; constraints/triggers/colunas conferidos; consulta restaurada preserva origem e versão 3 | +| Modelo dos ecrãs congelados, limites monetários exactos, envelopes de retry e headers | 23 controlos Node; sem navegador | +| PHPStan dirigido, TypeScript, lint/formatação e build frontend | Aprovados; sem erros PHPStan ou avisos lint nos ficheiros verificados | +| Activação e permissões no SGF de destino | Ponteiro 0.1.1 preservado; candidata recusada pelo updater normal; nenhuma concessão aplicada | + +## Percurso de actualização + +### Separação entre preparação e instalação cliente + +| Etapa | Acesso necessário | Dependência de Compras na instalação cliente | +| --- | --- | --- | +| Produzir ZIP validado (`publish-purchasing.php`) | Fonte Compras e canal de saída; não precisa de pasta SGF | Nenhuma depois de produzido o artefacto | +| Coordenar prova/instalação no SGF de desenvolvimento (`transfer-purchasing.php`) | Pastas locais de Compras/SGF, wrapper e canal | É uma ferramenta de desenvolvimento, não o instalador obrigatório do cliente | +| Conferir/instalar no cliente (`purchasing-release.php`) | SGF do próprio cliente com dependências e ferramentas de arranque, ZIP e SHA-256 aprovado | Não usa pasta, base, sessão ou serviço de Compras | +| Executar a capacidade instalada | Loader, versão seleccionada e dependências locais do SGF | Não consulta a origem nem sincroniza dados com ela | + +Conferido por dois meios: teste em pasta cliente sintética com a pasta de origem renomeada para indisponível; e execução no SGF `0.1.1` verificando que todos os ficheiros PHP carregados pertencem ao próprio host. A segunda prova não arranca a aplicação nem liga à base. Isto comprova a independência do pacote actual, não a preparação completa de todos os futuros módulos de cliente. + +No desenvolvimento, a partir de Compras: + +```text +composer purchasing:transfer -- C:/laragon/www/sgf-tempo C:/laragon/data/sgc-releases/purchasing +``` + +O comando usa o wrapper e o PHP fixado pelo SGF, sem substituir versões. Executa regressões da capacidade e do mecanismo; verifica que a origem não mudou durante os testes; prova uma fotografia imutável; publica essa mesma fotografia; compara com a versão instalada; prova a candidata com o PHP/dependências do SGF; instala se for mais recente. Sem alterações no pacote, não gera versão nem reinstala código. O exemplo de caminho é do ambiente local, não dependência do runtime ou do formato distribuído. + +Para apenas produzir uma versão validada, usar `composer purchasing:publish -- `. O comando `scripts/purchasing-release.php publish` é a primitiva técnica sem execução de testes; a publicação normal usa o percurso validado anterior. + +O canal contém `catalog.json` e ZIPs imutáveis. Há numeração patch automática por alteração dos bytes; `source_sha256` identifica o conjunto exacto de ficheiros. O manifesto individual contém SHA-256 por ficheiro e contrato/capacidades do módulo. A validação rejeita ficheiros extra, ausentes, alterados, caminhos inválidos, entradas duplicadas e arquivos excessivos. + +Na instalação cliente, distribuir o ZIP, os artefactos de arranque e a referência SHA-256 por canal de confiança. Não é necessário o checkout de Compras: + +```text +php scripts/purchasing-release.php check +php scripts/purchasing-release.php install +php scripts/purchasing-release.php status +php scripts/purchasing-release.php rollback +``` + +O SHA-256 confere integridade contra a referência aprovada; não autentica sozinho um canal remoto. Não há download ou activação a partir de URLs arbitrários. Um catálogo remoto assinado e verificação de assinatura ainda não integram esta entrega. + +## Preservação e recuperação + +Cada versão ocupa `packages/purchasing/releases/`; `current.json` é o ponteiro local. A instalação valida a versão actual antes de actualizar, prepara a candidata separadamente, regista histórico e troca o ponteiro atomicamente sob lock. Não apaga a versão anterior. Uma alteração local ao código instalado bloqueia a actualização em vez de a sobrescrever. Versão igual com conteúdo diferente e downgrade implícito são recusados. A reversão explícita confere os bytes anteriores e não recalcula nem apaga dados. + +`current.json`, locks e histórico são estado da instalação e não credenciais/contas distribuídas. O SGF pode arrancar sem este módulo opcional instalado; depois da instalação, o provider carrega o pacote verificado. O histórico é local e não deve ser transplantado de uma empresa para outra. + +O formato actual **recusa pacotes com alterações de schema ou concessões**. Os comandos de instalação/prova do primeiro incremento não abriram bases nem executaram migrações, seeds, alterações de contas, permissões, stock, finanças ou contratos. A nova candidata de requisições ensaiou persistência apenas em bases sintéticas descartáveis, conforme secção própria, sem alargar o contrato do updater. Serviços persistentes devem ser reiniciados pelo procedimento operacional depois de actualizar; o instalador não termina processos. + +### Alcance exacto da reversão + +| Elemento | Cobertura actual | +| --- | --- | +| Código do pacote e versão seleccionada | Reversão explícita para a versão anterior, depois de conferir integridade; histórico local e troca de ponteiro | +| Dependências PHP/Composer e frontend do host | Não são substituídas nem revertidas por este mecanismo | +| Dados empresariais, schema, configuração e permissões | Não são alterados nem revertidos pelo actual contrato | +| Migrações de dados/schema | Fora do updater; na candidata interna, DDL falhada, compatibilidade aditiva e backup/restauro em base isolada ensaiados separadamente; sem down/restauro automático | + +O manifesto exige `database_changes=false` e o formato não aceita pastas de migração/seed; isso delimita o contrato distribuído actual, não torna PHP futuro uma sandbox. Uma capacidade com persistência exige antes um contrato de migração com autoria/versão, schema compatível, aplicação idempotente, auditoria, destino conferido e backup/restauro ensaiados em isolamento. Deve distinguir reversão de código compatível, correcção por migração seguinte e eventual restauro de dados com plano de reconciliação. Não executar automaticamente `down`, apagar históricos ou restaurar um backup antigo sobre novas operações. A estratégia concreta terá de acompanhar o percurso escolhido. + +## Provas e limites + +**Conferência adicional antes de ampliar:** seis testes do mecanismo/29 asserções, incluindo execução em cliente com origem indisponível; prova SGF em PHP 8.4.27, versão activa `0.1.1`, todos os ficheiros carregados no próprio host e nenhuma ligação à base. Ver `proofs/purchasing-client-independence.json`. As provas do primeiro incremento abaixo mantêm o seu âmbito histórico. A candidata `0.1.2` contém preparação económica, mas não foi instalada; não contar os testes dessa preparação como prova do processo económico no SGF. + +- Compras: 86 testes/566 asserções no percurso de publicação (reposição, histórico, lotes, coordenação, sugestões, estornos, contagens e cinco testes do mecanismo). +- SGF: dois testes/11 asserções, executados com o wrapper e PHP 8.4.27, sem base; execução real do pacote e resultados de consumo manual, média racional e lotes. +- Publicação `0.1.0` e actualização `0.1.1` instaladas localmente. A segunda versão acrescenta documentação do contrato de distribuição; o algoritmo transferido permaneceu igual. +- Reversão real `0.1.1 → 0.1.0` e reactivação `0.1.1` conferidas, sem dados/permissões alterados. +- Cinco testes do instalador cobrem publicação sem duplicação, nova versão, retry, rollback/reactivação, código local alterado, manifesto errado, conteúdo adulterado, path traversal, ficheiro ausente, segredo na origem e pedido de alteração de schema. +- Fachada e motor comum passaram análise PHP dirigida. Fonte do mecanismo e artefactos SGF conferidos em `packages/module-updates/runtime-manifest.json`. + +**Limitação central:** este mecanismo actualiza as capacidades já extraídas para o pacote; não transfere automaticamente melhorias ainda confinadas aos controladores/serviços/ecrãs de Compras. Continuar a migração pela matriz acima, sem declarar todo o SGF actualizado só porque a versão do pacote mudou. Não há publicação no domínio ou conclusão de integração de RH nesta entrega. diff --git a/integrations/sgf/access/AccessOverlay.php b/integrations/sgf/access/AccessOverlay.php new file mode 100644 index 0000000..b7b66a9 --- /dev/null +++ b/integrations/sgf/access/AccessOverlay.php @@ -0,0 +1,57 @@ + 'Consultar requisições', 'purchasing_requisitions.prepare' => 'Preparar e submeter requisições', + 'purchasing_requisitions.configure' => 'Configurar Compras internas', 'purchasing_requisitions.approve.department_head' => 'Aprovar como responsável de departamento', + 'purchasing_requisitions.approve.procurement' => 'Aprovar como responsável de Compras', 'purchasing_requisitions.approve.finance' => 'Aprovar como responsável financeiro', + 'purchasing_budget.create' => 'Preparar dotações', 'purchasing_budget.approve' => 'Aprovar dotações', 'purchasing_budget.record' => 'Registar e libertar reservas orçamentais', + 'purchasing_budget.consume' => 'Registar consumo orçamental', 'purchasing_budget.view' => 'Consultar orçamento', + 'purchasing_budget.reinforcement.propose' => 'Propor reforços orçamentais', 'purchasing_budget.reinforcement.review' => 'Rever reforços orçamentais', + ]; + + public const RIGHTS = [ + 'purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure', + 'purchasing_requisitions.approve.department_head', 'purchasing_requisitions.approve.procurement', 'purchasing_requisitions.approve.finance', + 'purchasing_budget.create', 'purchasing_budget.approve', 'purchasing_budget.record', 'purchasing_budget.consume', 'purchasing_budget.view', + 'purchasing_budget.reinforcement.propose', 'purchasing_budget.reinforcement.review', + ]; + + public static function prepare(string $host): array + { + $paths = ['app/Modules/Identity/Services/Permissions.php', 'app/Modules/Identity/Services/IdentityWorkflow.php']; + $result = []; + foreach ($paths as $path) { + $source = file_get_contents($host.'/'.$path); + $before = hash('sha256', $source); + if (str_ends_with($path, '/Permissions.php')) { + $source = self::replace($source, "public const UNIT = ['unit.view', 'unit.edit'];", 'public const UNIT = '.var_export(['unit.view', 'unit.edit', ...self::RIGHTS], true).';'); + } else { + // Expanding a catalogue must never expand creation-time grants. + $source = self::replace($source, 'foreach (Permissions::UNIT as $permission)', "foreach (['unit.view', 'unit.edit'] as \$permission)"); + $source = self::replace($source, + "if (\$unit && \$data['permission'] === 'unit.edit')", + "if (\$unit && \$data['permission'] !== 'unit.view')"); + $source = self::replace($source, + "if (\$this->permissions->has(\$target->id, \$tenant, \$company, 'unit.edit', \$unit)) { \$this->permissions->require(\$actor, \$tenant, \$company, 'unit.edit', \$unit); }", + "foreach ((clone \$query)->where('unit_id', \$unit)->whereIn('permission', Permissions::UNIT)->get() as \$existing) { \$this->permissions->require(\$actor, \$tenant, \$company, \$existing->permission, \$unit); }"); + } + $result[$path] = ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source]; + } + + return $result; + } + + private static function replace(string $source, string $old, string $new): string + { + if (substr_count($source, $old) !== 1) { + throw new \RuntimeException('Host divergiu: reconciliar a alteração de identidade antes de preparar a entrega.'); + } + + return str_replace($old, $new, $source); + } +} diff --git a/integrations/sgf/access/InitialAuthority.php b/integrations/sgf/access/InitialAuthority.php new file mode 100644 index 0000000..068acf1 --- /dev/null +++ b/integrations/sgf/access/InitialAuthority.php @@ -0,0 +1,93 @@ +identifiers() + ['unit_id' => $context->unitId()]; + $actor = $request->user()->id; + abort_unless($scope['unit_id'] !== null && $context->userId() === $actor, 403); + abort_unless(in_array(implode(':', $scope), config('purchasing-access.initialisation_scopes', []), true), 403); + $permissions = app(Permissions::class); + $context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']); + $permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'access.manage'); + $members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id') + ->where('m.tenant_id', $scope['tenant_id'])->where('m.company_id', $scope['company_id'])->get(['u.id', 'u.name'])->filter(function ($member) use ($permissions, $scope) { + return $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'company.view') + && $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'access.manage') + && $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']); + })->values()->all(); + + return ['scope' => $scope, 'administrators' => $members, 'rights' => AccessOverlay::RIGHTS, 'labels' => AccessOverlay::LABELS, + 'initialised' => DB::table('identity_audit')->where($scope)->where('action', 'purchasing.access_initialised')->exists()]; + } + + public function initialise(Request $request): array + { + $data = $request->validate([ + 'operation_key' => 'required|uuid', 'reason' => 'required|string|min:10|max:500', + 'administrators' => 'required|array|min:1|max:20', 'administrators.*' => 'array:user_id,rights', + 'administrators.*.user_id' => 'required|integer|distinct|min:1', + 'administrators.*.rights' => 'required|array|min:1', 'administrators.*.rights.*' => 'required|string|distinct', + ]); + abort_unless(array_diff(array_keys($request->all()), array_keys($data)) === [], 422); + $context = app(CompanyContext::class); + $scope = $context->identifiers() + ['unit_id' => $context->unitId()]; + $actor = $request->user()->id; + abort_unless($scope['unit_id'] !== null && $context->userId() === $actor, 403); + // Installed configuration explicitly permits initialisation for this exact scope. + $scopeKey = implode(':', $scope); + abort_unless(in_array($scopeKey, config('purchasing-access.initialisation_scopes', []), true), 403); + foreach ($data['administrators'] as &$admin) { + abort_unless(array_diff($admin['rights'], AccessOverlay::RIGHTS) === [], 422); + sort($admin['rights']); + } + unset($admin); + usort($data['administrators'], fn ($a, $b) => $a['user_id'] <=> $b['user_id']); + $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + + return DB::transaction(function () use ($request, $scope, $actor, $context, $data, $hash) { + DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail(); + $context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']); + $permissions = app(Permissions::class); + $permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'access.manage'); + if ($request->hasSession()) { + $token = (string) $request->session()->get('company_context.token', ''); + abort_unless($token !== '' && hash_equals($token, (string) $request->header('X-SGF-Context')), 409); + } + $old = DB::table('identity_audit')->where($scope)->where('action', 'purchasing.access_initialised')->first(); + if ($old) { + $details = json_decode($old->details, true, 32, JSON_THROW_ON_ERROR); + abort_unless($old->actor_id === $actor && ($details['event_id'] ?? null) === $hash, 409, 'Autoridade inicial já entregue; use a gestão normal de acessos.'); + + return ['initialised' => true, 'replayed' => true]; + } + $grants = []; + foreach ($data['administrators'] as $admin) { + foreach (['company.view', 'access.manage'] as $right) { + $permissions->require($admin['user_id'], $scope['tenant_id'], $scope['company_id'], $right); + } + $permissions->require($admin['user_id'], $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']); + abort_unless(DB::table('company_memberships')->where(['user_id' => $admin['user_id'], 'tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id']])->exists(), 403); + foreach ($admin['rights'] as $right) { + abort_unless(in_array($right, Permissions::UNIT, true), 503, 'Catálogo operacional não preparado.'); + DB::table('permission_grants')->insertOrIgnore($scope + ['user_id' => $admin['user_id'], 'permission' => $right, 'created_at' => now(), 'updated_at' => now()]); + $grants[] = ['user_id' => $admin['user_id'], 'permission' => $right]; + } + } + Audit::record('purchasing.access_initialised', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['reason' => $data['reason'], 'event_id' => $hash, 'grants' => $grants]); + + return ['initialised' => true, 'replayed' => false]; + }); + } +} diff --git a/integrations/sgf/access/routes.php b/integrations/sgf/access/routes.php new file mode 100644 index 0000000..648613d --- /dev/null +++ b/integrations/sgf/access/routes.php @@ -0,0 +1,23 @@ +group(function () { + Route::get('/purchasing/internal-requisitions/access-initialisation', function (Request $request) { + $options = (new InitialAuthority)->options($request); + ob_start(); + require __DIR__.'/screen.php'; + + return response(ob_get_clean())->header('Cache-Control', 'private, no-store'); + }); + Route::post('/api/internal-requisitions/access-initialisation', function (Request $request) { + return response()->json((new InitialAuthority)->initialise($request))->header('Cache-Control', 'private, no-store'); + }); +}); diff --git a/integrations/sgf/access/screen.php b/integrations/sgf/access/screen.php new file mode 100644 index 0000000..6a11389 --- /dev/null +++ b/integrations/sgf/access/screen.php @@ -0,0 +1,51 @@ + +Configuração inicial de acessos a Compras internas + +

Configuração inicial de acessos a Compras internas

+

Empresa · Unidade

+

Escolha explicitamente o administrador e os direitos iniciais. As restantes atribuições e revogações usam a administração de acessos existente.

+ +

A autoridade inicial já foi entregue. Utilize a administração de acessos para alterações.

+ +
+ + +
Direitos iniciais escolhidos para esta unidade + +
+ +
+ + +
+

+ + +
diff --git a/integrations/sgf/internal-requisitions/Controller.php b/integrations/sgf/internal-requisitions/Controller.php new file mode 100644 index 0000000..d2ddbfd --- /dev/null +++ b/integrations/sgf/internal-requisitions/Controller.php @@ -0,0 +1,174 @@ +actorId()) { + try { + $port->authorize('save', $record); + $actions = ['edit', 'submit']; + } catch (HttpException $e) { + if (! in_array($e->getStatusCode(), [403, 404], true)) { + throw $e; + } + } + } elseif (($next = InternalRequisitions::nextStep($record, $port->actorId())) !== null) { + try { + $port->authorize('step:'.$next['role'], $record); + $actions = ['approved']; + $s = $port->scope(); + if (app(Permissions::class)->has($port->actorId(), $s['tenant_id'], $s['company_id'], 'purchasing_budget.record', $s['unit_id'])) { + $actions[] = 'rejected'; + } + } catch (HttpException $e) { + if (! in_array($e->getStatusCode(), [403, 404], true)) { + throw $e; + } + } + } + + return $record + ['allowed_actions' => $actions]; + } + + private function port(Request $request, string $key = ''): Port + { + $actor = $request->user(); + abort_unless($actor instanceof User, 403); + + return new Port($actor, $key); + } + + public function index(Request $request) + { + $port = $this->port($request); + $data = $port->atomic(function () use ($port) { + if (! $port->query('policies')->exists()) { + $port->authorize('configure', null); + + return ['context' => 'procurement.internal', 'scope' => $port->scope(), 'records' => [], 'centres' => [], 'configurationSources' => $port->configurationSources(), 'policy' => null, 'permissions' => ['configure' => true, 'prepare' => false]]; + } + $port->authorize('view', null); + $records = []; + foreach ($port->query('requests')->orderByDesc('id')->limit(100)->pluck('id') as $id) { + $record = $port->load((int) $id); + try { + $port->authorize('view', $record); + $records[] = $this->project($port, $record); + } catch (HttpException $e) { + if ($e->getStatusCode() !== 404) { + throw $e; + } + } + } + $policy = $port->setup(); + + return ['context' => 'procurement.internal', 'scope' => $port->scope(), 'records' => $records, 'centres' => $port->allowedCentres(), 'configurationSources' => $port->configurationSources(), 'policy' => $port->has('configure') ? $policy : array_intersect_key($policy, array_flip(['revision'])), + 'permissions' => array_combine(['prepare', 'configure', 'department_head', 'procurement', 'finance'], array_map(fn ($p) => $port->has($p), ['prepare', 'configure', 'approve.department_head', 'approve.procurement', 'approve.finance']))]; + }); + + return response()->json($data)->header('Cache-Control', 'private, no-store'); + } + + public function show(Request $request, int $id) + { + $port = $this->port($request); + $result = $port->atomic(function () use ($port, $id) { + $record = $port->load($id); + $port->authorize('view', $record); + + return ['record' => $this->project($port, $record), 'history' => $port->query('events')->where('request_id', $id)->orderBy('version')->get()]; + }); + + return response()->json($result)->header('Cache-Control', 'private, no-store'); + } + + public function command(Request $request, string $action, ?int $id = null) + { + $meta = $request->validate(['operation_key' => 'required|uuid', 'version' => ($id === null ? 'nullable' : 'required').'|integer|min:1']); + $port = $this->port($request, $meta['operation_key']); + $input = $request->except(['operation_key', 'version']); + if ($action === 'submit') { + abort_unless($input === [], 422, 'Campo protegido.'); + } + if ($action === 'decide') { + $input = Validator::make($input, ['decision' => 'required|in:approved,rejected', 'comment' => 'nullable|required_if:decision,rejected|string|max:2000'])->validate(); + } + try { + $result = (new InternalRequisitions($port))->command($action, $id, $meta['version'] ?? null, $meta['operation_key'], $input); + } catch (RequisitionFailure $e) { + return response()->json(['message' => $e->getMessage()], $e->status); + } + + return response()->json($result)->header('Cache-Control', 'private, no-store'); + } + + public function configure(Request $request) + { + $port = $this->port($request); + $data = $request->validate(['version' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'enabled' => 'required|boolean', 'budget_required' => 'required|boolean', + 'first_limit_minor' => 'required|integer|min:0|max:100000000000000', 'second_limit_minor' => 'required|integer|min:0|max:100000000000000', 'approval_includes_tax' => 'required|boolean', + 'centres' => 'required|array|min:1', 'centres.*.id' => 'required|integer|distinct|min:1', 'centres.*.department_ref' => 'required|string|max:100', + 'members' => 'required|array|min:1', 'members.*.user_id' => 'required|integer|distinct|min:1', 'members.*.department_ref' => 'required|string|max:100']); + abort_if(array_diff(array_keys($request->all()), array_keys($data)) !== [], 422, 'Campo protegido.'); + $port->atomic(function () use ($port, $data) { + $port->authorize('configure', null); + $scope = $port->scope(); + $old = $port->query('policies')->lockForUpdate()->first(); + abort_unless((int) ($old->version ?? 0) === $data['version'], 409, 'Política alterada.'); + abort_unless($data['second_limit_minor'] >= $data['first_limit_minor'], 422, 'Limites inválidos.'); + foreach ($data['centres'] as $c) { + abort_unless(DB::table('cb_centres')->where($scope)->where('id', $c['id'])->exists(), 403, 'Centro não autorizado.'); + } + foreach ($data['members'] as $m) { + abort_unless(DB::table('company_memberships')->where(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'user_id' => $m['user_id']])->exists(), 403, 'Pessoa não autorizada.'); + abort_unless(app(Permissions::class)->has($m['user_id'], $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']), 403, 'Unidade não autorizada.'); + } + $payload = $data; + unset($payload['version']); + $payload['rules_provisional'] = false; + $values = ['version' => $data['version'] + 1, 'actor_id' => $port->actorId(), 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'created_at' => now()]; + if ($old) { + $port->query('policies')->update($values); + } else { + DB::table('ir_policies')->insert($scope + $values); + } + DB::table('ir_policy_versions')->insert($scope + $values); + }); + + return response()->json(['configured' => true, 'version' => $data['version'] + 1]); + } + + public function page(Request $request) + { + $request->headers->set('X-Purchasing-Context', 'procurement.internal'); + $port = $this->port($request); + $port->atomic(fn () => $port->authorize($port->query('policies')->exists() ? 'view' : 'configure', null)); + $selection = $request->session()->get('company_context', []); + $scope = $port->scope(); + $contract = ['csrf' => csrf_token(), 'token' => $selection['token'] ?? '', 'scope' => $scope, 'context' => 'procurement.internal', 'actor_id' => $port->actorId(), + 'company' => DB::table('companies')->where('id', $scope['company_id'])->value('name'), 'unit' => DB::table('units')->where('id', $scope['unit_id'])->value('name')]; + ob_start(); + require __DIR__.'/screen.php'; + $html = ob_get_clean(); + + return response($html)->header('Cache-Control', 'private, no-store'); + } + + public function script(Request $request) + { + return response(file_get_contents(__DIR__.'/screen.mjs'))->header('Content-Type', 'text/javascript')->header('Cache-Control', 'private, no-store'); + } +} diff --git a/integrations/sgf/internal-requisitions/Port.php b/integrations/sgf/internal-requisitions/Port.php new file mode 100644 index 0000000..4673a71 --- /dev/null +++ b/integrations/sgf/internal-requisitions/Port.php @@ -0,0 +1,282 @@ +scope = $ctx->identifiers() + ['unit_id' => $ctx->unitId()]; + } + + public function actorId(): int + { + return $this->actor->id; + } + + public function scope(): array + { + return $this->scope; + } + + public function query(string $table): Builder + { + return DB::table('ir_'.$table)->where($this->scope); + } + + public function atomic(callable $work): mixed + { + BudgetAdapter::guard(); + abort_unless(config('purchasing-internal.candidate_enabled') === true, 404); + + return DB::transaction(function () use ($work) { + $s = $this->scope; + DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail(); + $this->actor = User::query()->lockForUpdate()->findOrFail($this->actor->id); + $ctx = app(CompanyContext::class); + abort_unless(auth()->id() === $this->actor->id && $ctx->userId() === $this->actor->id && $s['unit_id'] !== null + && $ctx->identifiers() + ['unit_id' => $ctx->unitId()] === $s, 409, 'Contexto alterado.'); + $ctx->selectFor($this->actor->id, $s['tenant_id'], $s['company_id'], $s['unit_id']); + abort_unless(request()->header('X-Purchasing-Context') === 'procurement.internal', 403, 'Escolha Compras internas.'); + if (request()->hasSession() && request()->is('api/*')) { + abort_unless(hash_equals(request()->session()->get('company_context.token', ''), (string) request()->header('X-SGF-Context')), 409, 'O contexto mudou.'); + } + + return $work(); + }); + } + + public function has(string $right): bool + { + $s = $this->scope; + + return app(Permissions::class)->has($this->actor->id, $s['tenant_id'], $s['company_id'], 'purchasing_requisitions.'.$right, $s['unit_id']); + } + + public function setup(): array + { + $row = $this->query('policies')->first(); + abort_unless($row, 422, 'Configure a política e os cadastros autorizados.'); + + return json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR) + ['revision' => (int) $row->version]; + } + + private function department(): ?string + { + foreach ($this->setup()['members'] as $member) { + if ($member['user_id'] === $this->actor->id) { + return $member['department_ref']; + } + } + + return null; + } + + public function allowedCentres(): array + { + $out = []; + foreach ($this->setup()['centres'] as $c) { + if ($this->has('configure') || $c['department_ref'] === $this->department()) { + $name = DB::table('cb_centres')->where($this->scope)->where('id', $c['id'])->value('code'); + $out[] = $c + ['label' => $name ?? 'Centro configurado']; + } + } + + return $out; + } + + public function configurationSources(): array + { + if (! $this->has('configure')) { + return []; + } + $s = $this->scope; + + return ['centres' => DB::table('cb_centres')->where($s)->get(['id', 'code'])->all(), + 'members' => DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')->where('m.tenant_id', $s['tenant_id'])->where('m.company_id', $s['company_id']) + ->whereExists(function ($q) use ($s) { + $q->selectRaw('1')->from('permission_grants as g')->whereColumn('g.user_id', 'u.id')->where('g.tenant_id', $s['tenant_id'])->where('g.company_id', $s['company_id'])->where('g.unit_id', $s['unit_id'])->where('g.permission', 'unit.view'); + })->get(['u.id', 'u.name'])->all()]; + } + + public function authorize(string $action, ?array $record): void + { + abort_unless($this->has('view'), 403, 'Consulta não autorizada.'); + if ($action !== 'configure') { + abort_unless($this->setup()['enabled'], 403, 'Opção interna não configurada para o produto.'); + } + if ($record !== null) { + abort_unless($record['work_context'] === 'procurement.internal', 403); + $all = $this->has('configure') || $this->has('approve.procurement') || $this->has('approve.finance'); + $visible = $all || $record['requester_id'] === $this->actor->id || ($this->has('approve.department_head') && $record['department_ref'] === $this->department()); + abort_unless($visible, 404); + } + if (in_array($action, ['save', 'submit'], true)) { + abort_unless($this->has('prepare') && $this->department() !== null, 403); + if ($record !== null) { + abort_unless($record['requester_id'] === $this->actor->id, 403); + } + } elseif ($action === 'configure') { + abort_unless($this->has('configure'), 403); + } elseif ($action === 'decide') { + abort_unless($this->has('approve.department_head') || $this->has('approve.procurement') || $this->has('approve.finance'), 403); + } elseif (str_starts_with($action, 'step:')) { + $role = substr($action, 5); + abort_unless($this->has('approve.'.$role) && ($role !== 'department_head' || $record['department_ref'] === $this->department()), 403); + } + } + + public function load(int $id): array + { + $row = $this->query('requests')->where('id', $id)->lockForUpdate()->first(); + abort_unless($row, 404); + $r = (array) $row; + foreach (['data', 'approval_snapshot', 'reservation'] as $field) { + $r[$field] = $r[$field] === null ? null : json_decode($r[$field], true, 64, JSON_THROW_ON_ERROR); + } + foreach (['id', 'requester_id', 'version', 'subtotal_minor', 'tax_minor', 'total_minor', 'cost_center_id'] as $field) { + $r[$field] = (int) $r[$field]; + } + $r['steps'] = $this->query('steps')->where('request_id', $id)->orderBy('position')->get()->map(fn ($s) => (array) $s)->all(); + $r['has_equipment'] = count(array_filter($r['data']['items'], fn ($i) => $i['purchase_type'] === 'equipment')) > 0; + + return $r; + } + + public function draft(array $input, ?int $id): array + { + $data = Validator::make($input, [ + 'cost_center_id' => 'required|integer|min:1', 'priority' => 'required|in:normal,high,urgent', 'needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'justification' => 'required|string|min:10|max:5000', + 'items' => 'required|array|min:1|max:50', 'items.*' => 'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate', 'items.*.description' => 'required|string|max:255', 'items.*.category' => 'required|string|max:100', 'items.*.purchase_type' => 'required|in:consumable,service,other', + 'items.*.unit' => 'required|string|max:30', 'items.*.quantity' => ['required', 'regex:/^\d{1,7}(\.\d{1,3})?$/D', 'numeric', 'min:0.001', 'max:1000000'], + 'items.*.unit_price' => ['required', 'regex:/^\d{1,9}(\.\d{1,2})?$/D', 'numeric', 'min:0.01', 'max:100000000'], + 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/D', 'numeric', 'between:0,100'], + ])->validate(); + abort_if(array_diff(array_keys($input), array_keys($data)) !== [], 422, 'Campo protegido.'); + $department = $this->department(); + $mapped = false; + foreach ($this->setup()['centres'] as $centre) { + if ($centre['id'] === (int) $data['cost_center_id'] && $centre['department_ref'] === $department) { + $mapped = true; + } + } + abort_unless($mapped && DB::table('cb_centres')->where($this->scope)->where('id', $data['cost_center_id'])->exists(), 403, 'Centro fora do cadastro autorizado.'); + $subtotal = 0; + $tax = 0; + foreach ($data['items'] as &$line) { + $line = [...$line, ...InternalRequisitions::line($line['quantity'], $line['unit_price'], $line['tax_rate'])]; + $subtotal += $line['subtotal_minor']; + $tax += $line['tax_minor']; + } + unset($line); + abort_if($subtotal + $tax > 100000000000000, 422, 'Total excede o limite suportado.'); + $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + if ($id === null) { + $old = $this->query('requests')->where('origin_key', $this->originKey)->first(); + if ($old) { + abort_unless((int) $old->requester_id === $this->actor->id && hash_equals($old->creation_hash, $hash), 409, 'Origem já registada com outros dados.'); + + return $this->load($old->id); + } + } + $values = ['cost_center_id' => $data['cost_center_id'], 'department_ref' => $department, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()]; + if ($id === null) { + $id = DB::table('ir_requests')->insertGetId($this->scope + $values + ['requester_id' => $this->actor->id, 'number' => 'IR-'.now()->year.'-'.$this->originKey, 'work_context' => 'procurement.internal', 'origin_key' => $this->originKey, 'creation_hash' => $hash, 'created_at' => now()]); + } else { + $old = $this->load($id); + $this->query('requests')->where('id', $id)->update($values + ['version' => $old['version'] + 1]); + } + + return $this->event($id, 'draft.saved'); + } + + public function policy(array $record): array + { + $policy = $this->setup(); + abort_unless($this->department() === $record['department_ref'], 422, 'A atribuição ao departamento mudou; reveja o rascunho.'); + abort_unless($record['data']['needed_by'] >= today()->toDateString(), 422, 'Actualize a data pretendida.'); + $mapped = false; + foreach ($policy['centres'] as $c) { + if ($c['id'] === $record['cost_center_id'] && $c['department_ref'] === $record['department_ref']) { + $mapped = true; + } + } + abort_unless($mapped, 422, 'A associação do centro mudou; reveja o rascunho.'); + + return array_intersect_key($policy, array_flip(['revision', 'first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional', 'budget_required'])); + } + + public function reserve(array $record): ?array + { + $origin = DB::table('cb_origins')->where($this->scope)->where('reference', 'internal-requisition:'.$record['origin_key'])->first(); + $id = $origin?->id ?? DB::table('cb_origins')->insertGetId($this->scope + ['cost_center_id' => $record['cost_center_id'], 'required_kind' => 'operating', 'reference' => 'internal-requisition:'.$record['origin_key']]); + $result = app(BudgetAdapter::class)->record($this->actor, (int) $id, 'reserve', $record['total_minor'], $record['subtotal_minor']); + abort_if($result === null && $this->setup()['budget_required'], 422, 'Dotação não configurada; nenhuma submissão ou reserva foi gravada.'); + // Persisted atomically with submission; no intermediate root mutation/version. + $this->pendingReservation = ['origin_id' => (int) $id, 'result' => $result]; + + return $result; + } + + private array $pendingReservation = []; + + public function submitted(array $record, array $policy, array $roles): array + { + foreach ($roles as $i => $role) { + DB::table('ir_steps')->insert($this->scope + ['request_id' => $record['id'], 'position' => $i + 1, 'role' => $role]); + } + $this->query('requests')->where('id', $record['id'])->update(['status' => 'pending', 'version' => $record['version'] + 1, 'approval_snapshot' => json_encode($policy, JSON_THROW_ON_ERROR), 'budget_origin_id' => $this->pendingReservation['origin_id'], 'reservation' => json_encode($this->pendingReservation['result'], JSON_THROW_ON_ERROR), 'updated_at' => now()]); + + return $this->event($record['id'], 'submitted'); + } + + public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array + { + $this->query('steps')->where('id', $step['id'])->update(['decision' => $decision, 'approver_id' => $this->actor->id, 'comment' => $comment, 'decided_at' => now()]); + $this->query('requests')->where('id', $record['id'])->update(['status' => $status, 'version' => $record['version'] + 1, 'updated_at' => now()]); + + return $this->event($record['id'], 'approval.'.$decision); + } + + public function release(array $record): void + { + app(BudgetAdapter::class)->record($this->actor, (int) $record['budget_origin_id'], 'release'); + } + + private function event(int $id, string $action): array + { + $record = $this->load($id); + DB::table('ir_events')->insert($this->scope + ['request_id' => $id, 'version' => $record['version'], 'actor_id' => $this->actor->id, 'action' => $action, 'payload' => json_encode($record, JSON_THROW_ON_ERROR), 'created_at' => now()]); + + return $record; + } + + public function replay(string $key, string $hash): ?array + { + $old = $this->query('commands')->where(['actor_id' => $this->actor->id, 'operation_key' => $key])->first(); + if (! $old) { + return null; + } + abort_unless(hash_equals($old->request_hash, $hash), 409, 'Chave usada com outro conteúdo.'); + + return json_decode($old->result, true, 64, JSON_THROW_ON_ERROR); + } + + public function remember(string $key, string $hash, array $result): void + { + DB::table('ir_commands')->insert($this->scope + ['actor_id' => $this->actor->id, 'operation_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]); + } +} diff --git a/integrations/sgf/internal-requisitions/Schema.php b/integrations/sgf/internal-requisitions/Schema.php new file mode 100644 index 0000000..8987b5b --- /dev/null +++ b/integrations/sgf/internal-requisitions/Schema.php @@ -0,0 +1,118 @@ +getSchemaBuilder(); + if ($schema->hasTable('ir_requests')) { + throw new \RuntimeException('Schema já existe; não redefinir dados.'); + } + $db->transaction(function () use ($schema, $db) { + if ($db->getDriverName() === 'pgsql') { + $db->statement('ALTER TABLE permission_grants ALTER COLUMN permission TYPE varchar(100)'); + $check = $db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'"); + if (! $check || ! preg_match('/^CHECK\s*\((.*)\)$/s', $check->definition, $parts)) { + throw new \RuntimeException('Conferir o catálogo de permissões antes de migrar.'); + } + $db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check'); + $db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$parts[1]}) OR (unit_id IS NOT NULL AND permission::text IN ('purchasing_requisitions.view'::text,'purchasing_requisitions.prepare'::text,'purchasing_requisitions.configure'::text,'purchasing_requisitions.approve.department_head'::text,'purchasing_requisitions.approve.procurement'::text,'purchasing_requisitions.approve.finance'::text)))"); + } + $schema->create('ir_requests', function (Blueprint $t) { + $t->id(); + foreach (['tenant_id', 'company_id', 'unit_id', 'requester_id', 'cost_center_id'] as $c) { + $t->unsignedBigInteger($c); + } + $t->string('work_context', 40)->default('procurement.internal'); + $t->uuid('origin_key'); + $t->string('creation_hash', 64); + $t->string('number')->nullable(); + $t->string('department_ref', 100); + $t->string('status')->default('draft'); + $t->unsignedInteger('version')->default(1); + $t->bigInteger('subtotal_minor'); + $t->bigInteger('tax_minor'); + $t->bigInteger('total_minor'); + $t->jsonb('data'); + $t->jsonb('approval_snapshot')->nullable(); + $t->unsignedBigInteger('budget_origin_id')->nullable(); + $t->jsonb('reservation')->nullable(); + $t->timestamps(); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'origin_key']); + $t->unique(['id', 'tenant_id', 'company_id', 'unit_id']); + }); + foreach (['steps', 'events', 'commands', 'policies', 'policy_versions'] as $kind) { + $schema->create('ir_'.$kind, function (Blueprint $t) use ($kind) { + $t->id(); + foreach (['tenant_id', 'company_id', 'unit_id'] as $c) { + $t->unsignedBigInteger($c); + } + if (in_array($kind, ['steps', 'events'], true)) { + $t->unsignedBigInteger('request_id'); + $t->foreign(['request_id', 'tenant_id', 'company_id', 'unit_id'])->references(['id', 'tenant_id', 'company_id', 'unit_id'])->on('ir_requests')->restrictOnDelete(); + } + if ($kind === 'steps') { + $t->unsignedInteger('position'); + $t->string('role'); + $t->string('decision')->default('pending'); + $t->unsignedBigInteger('approver_id')->nullable(); + $t->text('comment')->nullable(); + $t->timestamp('decided_at')->nullable(); + $t->unique(['request_id', 'position']); + $t->unique(['request_id', 'approver_id']); + } elseif ($kind === 'commands') { + $t->unsignedBigInteger('actor_id'); + $t->uuid('operation_key'); + $t->string('request_hash', 64); + $t->jsonb('result'); + $t->timestamp('created_at'); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'operation_key']); + } else { + $t->unsignedInteger('version'); + $t->unsignedBigInteger('actor_id'); + $t->jsonb('payload'); + $t->timestamp('created_at'); + if ($kind === 'events') { + $t->string('action'); + $t->unique(['request_id', 'version']); + } else { + $t->unique($kind === 'policies' ? ['tenant_id', 'company_id', 'unit_id'] : ['tenant_id', 'company_id', 'unit_id', 'version']); + } + } + }); + } + if ($db->getDriverName() === 'pgsql') { + $db->statement("ALTER TABLE ir_requests ADD CONSTRAINT ir_internal_context CHECK (work_context='procurement.internal')"); + foreach (['ir_events', 'ir_commands', 'ir_policy_versions'] as $table) { + $db->unprepared("CREATE FUNCTION {$table}_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Append-only history'; END; \$\$; CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION {$table}_immutable();"); + } + $db->unprepared(<<<'SQL' +CREATE FUNCTION ir_origin_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Preserve requisition origin'; END IF; + IF (NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.work_context,NEW.origin_key,NEW.requester_id,NEW.creation_hash) IS DISTINCT FROM (OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.work_context,OLD.origin_key,OLD.requester_id,OLD.creation_hash) THEN RAISE EXCEPTION 'Immutable origin'; END IF; + IF OLD.status <> 'draft' AND (NEW.data,NEW.subtotal_minor,NEW.tax_minor,NEW.total_minor,NEW.cost_center_id,NEW.department_ref,NEW.approval_snapshot,NEW.budget_origin_id,NEW.reservation) IS DISTINCT FROM (OLD.data,OLD.subtotal_minor,OLD.tax_minor,OLD.total_minor,OLD.cost_center_id,OLD.department_ref,OLD.approval_snapshot,OLD.budget_origin_id,OLD.reservation) THEN RAISE EXCEPTION 'Preserve submitted snapshot'; END IF; + IF NEW.version <= OLD.version THEN RAISE EXCEPTION 'Version must advance'; END IF; + RETURN NEW; +END; $$; +CREATE TRIGGER ir_origin_guard BEFORE UPDATE OR DELETE ON ir_requests FOR EACH ROW EXECUTE FUNCTION ir_origin_guard(); +CREATE FUNCTION ir_step_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Preserve approval steps'; END IF; + IF OLD.decision <> 'pending' THEN RAISE EXCEPTION 'Final decision immutable'; END IF; + IF NEW.approver_id = (SELECT requester_id FROM ir_requests WHERE id=NEW.request_id) THEN RAISE EXCEPTION 'Independent identity required'; END IF; + IF (NEW.request_id,NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.position,NEW.role) IS DISTINCT FROM (OLD.request_id,OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.position,OLD.role) THEN RAISE EXCEPTION 'Step origin immutable'; END IF; + RETURN NEW; +END; $$; +CREATE TRIGGER ir_step_guard BEFORE UPDATE OR DELETE ON ir_steps FOR EACH ROW EXECUTE FUNCTION ir_step_guard(); +SQL); + } + }); + } +} diff --git a/integrations/sgf/internal-requisitions/bootstrap.php b/integrations/sgf/internal-requisitions/bootstrap.php new file mode 100644 index 0000000..b7c215a --- /dev/null +++ b/integrations/sgf/internal-requisitions/bootstrap.php @@ -0,0 +1,10 @@ + true]); diff --git a/integrations/sgf/internal-requisitions/routes.php b/integrations/sgf/internal-requisitions/routes.php new file mode 100644 index 0000000..e1f728c --- /dev/null +++ b/integrations/sgf/internal-requisitions/routes.php @@ -0,0 +1,18 @@ +group(function () { + Route::get('/purchasing/internal-requisitions', [Controller::class, 'page']); + Route::get('/purchasing/internal-requisitions/screen.mjs', [Controller::class, 'script']); + Route::get('/api/internal-requisitions', [Controller::class, 'index']); + Route::get('/api/internal-requisitions/{id}', [Controller::class, 'show'])->whereNumber('id'); + Route::post('/api/internal-requisitions/policy', [Controller::class, 'configure']); + Route::post('/api/internal-requisitions/save', [Controller::class, 'command'])->defaults('action', 'save'); + Route::post('/api/internal-requisitions/{id}/{action}', fn (Request $r, int $id, string $action) => (new Controller)->command($r, $action, $id))->whereNumber('id')->whereIn('action', ['save', 'submit', 'decide']); +}); diff --git a/integrations/sgf/internal-requisitions/screen.mjs b/integrations/sgf/internal-requisitions/screen.mjs new file mode 100644 index 0000000..9b6288b --- /dev/null +++ b/integrations/sgf/internal-requisitions/screen.mjs @@ -0,0 +1,429 @@ +export function actionsFor(record, actor, permissions) { + if (Array.isArray(record.allowed_actions)) return record.allowed_actions; + const own = record.requester_id === actor; + if (record.status === 'draft') + return own && permissions.prepare ? ['edit', 'submit'] : []; + if ( + record.status !== 'pending' || + own || + record.steps.some((step) => step.approver_id === actor) + ) + return []; + const next = record.steps.find((step) => step.decision === 'pending'); + return next && permissions[next.role] ? ['approved', 'rejected'] : []; +} +export function commandEnvelope(key, version, data = {}) { + return { + ...data, + operation_key: key, + ...(version == null ? {} : { version }), + }; +} +export function limitMinor(value) { + if (!/^\d+(\.\d{1,2})?$/.test(value)) + throw new Error('Indique o limite em MZN com até duas casas decimais.'); + const [major, fraction = ''] = value.split('.'); + const minor = BigInt(major) * 100n + BigInt(fraction.padEnd(2, '0')); + if (minor > 100000000000000n) + throw new Error('Limite superior ao montante suportado.'); + return Number(minor); +} +export function limitMajor(minor) { + const value = BigInt(minor); + return `${value / 100n}.${String(value % 100n).padStart(2, '0')}`; +} +if (typeof document !== 'undefined') { + const contract = JSON.parse( + document.querySelector('#ir-contract').textContent, + ); + const $ = (id) => document.getElementById(id); + const el = (tag, text, parent) => { + const node = document.createElement(tag); + if (text != null) node.textContent = text; + if (parent) parent.append(node); + return node; + }; + let state, + selected, + draft, + dirty = false, + busy = false; + const keys = new Map(); + const money = (minor) => + new Intl.NumberFormat('pt-MZ', { + style: 'currency', + currency: 'MZN', + }).format(minor / 100); + function notice(text) { + $('notice').textContent = text; + $('notice').className = text ? 'notice' : ''; + } + async function api(path, method = 'GET', data) { + const response = await fetch(path, { + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': contract.csrf, + 'X-SGF-Context': contract.token, + 'X-Purchasing-Context': contract.context, + }, + ...(data ? { body: JSON.stringify(data) } : {}), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error( + Object.values(result.errors || {}) + .flat() + .join(' ') || + result.message || + 'Operação não concluída. O contexto e a chave de repetição foram mantidos.', + ); + return result; + } + async function perform(work) { + if (busy) return; + busy = true; + notice('A processar…'); + document.querySelectorAll('button').forEach((b) => (b.disabled = true)); + try { + await work(); + } catch (error) { + notice(error.message); + } finally { + busy = false; + document + .querySelectorAll('button') + .forEach((b) => (b.disabled = false)); + } + } + async function command(action, record, data = {}) { + const slot = `${action}:${record?.id ?? 'new'}`; + if (!keys.has(slot)) keys.set(slot, crypto.randomUUID()); + const result = await api( + `/api/internal-requisitions/${record ? `${record.id}/${action}` : 'save'}`, + 'POST', + commandEnvelope(keys.get(slot), record?.version, data), + ); + keys.delete(slot); + dirty = false; + selected = result; + if (action === 'save') draft = result; + try { + await load(); + await detail(result.id); + notice( + result.replayed + ? 'Resposta recuperada sem repetir efeitos.' + : 'Operação registada com origem e versão.', + ); + } catch { + notice( + 'Operação confirmada. Não foi possível actualizar a consulta; use Actualizar. A origem gravada foi mantida.', + ); + } + return result; + } + function line(data = {}) { + const node = el('div', null, $('lines')); + node.className = 'line'; + for (const [name, label, value] of [ + ['description', 'Descrição', ''], + ['category', 'Categoria', ''], + ['quantity', 'Quantidade', '1.000'], + ['unit', 'Unidade', 'UN'], + ['unit_price', 'Preço unitário MZN', ''], + ['tax_rate', 'IVA %', ''], + ]) { + const labelNode = el('label', label, node), + input = el('input', null, labelNode); + input.name = name; + input.value = data[name] ?? value; + input.required = true; + if (['quantity', 'unit_price', 'tax_rate'].includes(name)) + input.inputMode = 'decimal'; + } + const label = el('label', 'Tipo', node), + type = el('select', null, label); + type.name = 'purchase_type'; + for (const [value, text] of [ + ['consumable', 'Consumível'], + ['service', 'Serviço'], + ['other', 'Outro'], + ]) { + const option = el('option', text, type); + option.value = value; + } + type.value = data.purchase_type || 'consumable'; + const remove = el('button', 'Remover', node); + remove.type = 'button'; + remove.onclick = () => { + node.remove(); + dirty = true; + }; + } + function edit(record) { + if (dirty && !confirm('Descartar as alterações por guardar?')) return; + draft = record; + $('draft-panel').hidden = false; + $('draft-title').textContent = record + ? `Editar ${record.number}` + : 'Nova requisição interna'; + $('lines').replaceChildren(); + const form = $('draft'); + for (const name of [ + 'cost_center_id', + 'priority', + 'needed_by', + 'justification', + ]) + form.elements[name].value = + record?.data[name] ?? (name === 'priority' ? 'normal' : ''); + (record?.data.items ?? [{}]).forEach(line); + dirty = false; + } + async function detail(id) { + if (dirty && !confirm('Descartar as alterações por guardar?')) return; + const result = await api(`/api/internal-requisitions/${id}`); + selected = result.record; + $('detail').hidden = false; + $('detail-title').textContent = + `${selected.number} · ${selected.status} · Versão ${selected.version}`; + $('detail-data').replaceChildren(); + el('p', selected.data.justification, $('detail-data')); + el( + 'p', + `Total: ${money(selected.total_minor)} · Origem: ${selected.origin_key}`, + $('detail-data'), + ); + if (selected.status !== 'draft') + el( + 'p', + selected.reservation + ? 'Reserva orçamental registada.' + : 'Sem reserva: política aplicada não a exigia e não havia dotação configurada.', + $('detail-data'), + ); + $('steps').replaceChildren(); + selected.steps.forEach((step) => + el( + 'p', + `${step.position}. ${step.role} · ${step.decision}${step.approver_id ? ` · Identidade ${step.approver_id}` : ''}`, + $('steps'), + ), + ); + $('history').replaceChildren(); + result.history.forEach((event) => + el( + 'p', + `Versão ${event.version} · ${event.action} · ${event.created_at}`, + $('history'), + ), + ); + $('actions').replaceChildren(); + for (const action of actionsFor( + selected, + contract.actor_id, + state.permissions, + )) { + const button = el( + 'button', + { + edit: 'Editar', + submit: 'Submeter', + approved: 'Aprovar etapa', + rejected: 'Rejeitar', + }[action], + $('actions'), + ); + button.onclick = () => + action === 'edit' + ? edit(selected) + : perform(async () => { + const comment = + action === 'rejected' + ? prompt('Fundamente a rejeição') + : null; + if (action === 'rejected' && !comment) return; + await command( + action === 'approved' || action === 'rejected' + ? 'decide' + : action, + selected, + action === 'submit' + ? {} + : { decision: action, comment }, + ); + }); + } + } + function mapping(parent, data, idField) { + const row = el('div', null, $(parent)); + row.className = 'line'; + const label = el( + 'label', + idField === 'id' ? 'Centro de custo' : 'Pessoa', + row, + ), + select = el('select', null, label); + select.name = idField; + select.required = true; + const empty = el('option', 'Seleccione…', select); + empty.value = ''; + const sources = + state.configurationSources?.[ + idField === 'id' ? 'centres' : 'members' + ] ?? []; + for (const source of sources) { + const option = el('option', source.code ?? source.name, select); + option.value = source.id; + } + select.value = data?.[idField] ?? ''; + const departmentLabel = el('label', 'Departamento', row), + input = el('input', null, departmentLabel); + input.name = 'department_ref'; + input.value = data?.department_ref ?? ''; + input.required = true; + const b = el('button', 'Retirar associação', row); + b.type = 'button'; + b.onclick = () => { + row.remove(); + dirty = true; + }; + } + async function load() { + state = await api('/api/internal-requisitions'); + $('new').hidden = !state.permissions.prepare; + $('policy-panel').hidden = !state.permissions.configure; + const select = $('draft').elements.cost_center_id; + select.replaceChildren(); + (state.centres || []).forEach((c) => { + const o = el('option', `${c.label} · ${c.department_ref}`, select); + o.value = c.id; + }); + $('records').replaceChildren(); + if (!state.records.length) + el('p', 'Sem pedidos autorizados neste contexto.', $('records')); + state.records.forEach((r) => { + const b = el( + 'button', + `${r.number} · ${r.status} · ${money(r.total_minor)}`, + $('records'), + ); + b.onclick = () => perform(() => detail(r.id)); + }); + if (state.permissions.configure) { + const p = state.policy, + form = $('policy'); + for (const k of [ + 'enabled', + 'budget_required', + 'approval_includes_tax', + ]) + form.elements[k].checked = p?.[k] ?? false; + for (const k of ['first_limit_minor', 'second_limit_minor']) + form.elements[k].value = p ? limitMajor(p[k]) : ''; + $('policy-centres').replaceChildren(); + $('policy-members').replaceChildren(); + (p?.centres || []).forEach((c) => + mapping('policy-centres', c, 'id'), + ); + (p?.members || []).forEach((m) => + mapping('policy-members', m, 'user_id'), + ); + } + } + $('new').onclick = () => edit(null); + $('reload').onclick = () => perform(load); + $('add-line').onclick = () => { + line(); + dirty = true; + }; + $('draft').oninput = () => (dirty = true); + $('cancel-draft').onclick = () => { + if (!dirty || confirm('Descartar as alterações?')) { + $('draft-panel').hidden = true; + dirty = false; + } + }; + $('draft').onsubmit = (event) => { + event.preventDefault(); + void perform(async () => { + const form = event.target, + data = {}; + for (const k of [ + 'cost_center_id', + 'priority', + 'needed_by', + 'justification', + ]) + data[k] = + k === 'cost_center_id' + ? Number(form.elements[k].value) + : form.elements[k].value; + data.items = [...$('lines').children].map((row) => + Object.fromEntries( + [...row.querySelectorAll('input,select')].map((i) => [ + i.name, + i.value, + ]), + ), + ); + await command('save', draft, data); + $('draft-panel').hidden = true; + }); + }; + $('add-centre').onclick = () => { + mapping('policy-centres', null, 'id'); + dirty = true; + }; + $('add-member').onclick = () => { + mapping('policy-members', null, 'user_id'); + dirty = true; + }; + $('policy').oninput = () => (dirty = true); + $('policy').onsubmit = (event) => { + event.preventDefault(); + void perform(async () => { + const f = event.target, + data = { + version: state.policy?.revision ?? 0, + reason: f.elements.reason.value, + }; + for (const k of [ + 'enabled', + 'budget_required', + 'approval_includes_tax', + ]) + data[k] = f.elements[k].checked; + for (const k of ['first_limit_minor', 'second_limit_minor']) + data[k] = limitMinor(f.elements[k].value); + for (const [key, parent, id] of [ + ['centres', 'policy-centres', 'id'], + ['members', 'policy-members', 'user_id'], + ]) + data[key] = [...$(parent).children].map((row) => + Object.fromEntries( + [...row.querySelectorAll('input,select')].map((i) => [ + i.name, + i.name === id ? Number(i.value) : i.value, + ]), + ), + ); + await api('/api/internal-requisitions/policy', 'POST', data); + dirty = false; + await load(); + notice( + 'Parametrização versionada e registada. Permissões não foram alteradas.', + ); + }); + }; + window.addEventListener('beforeunload', (event) => { + if (dirty) { + event.preventDefault(); + event.returnValue = ''; + } + }); + void perform(load); +} diff --git a/integrations/sgf/internal-requisitions/screen.php b/integrations/sgf/internal-requisitions/screen.php new file mode 100644 index 0000000..2d92175 --- /dev/null +++ b/integrations/sgf/internal-requisitions/screen.php @@ -0,0 +1,10 @@ + +Requisições internas · SGF + +
SGF · Compras internas

Requisições internas

Candidata isolada de integração · sem activação na instalação de destino
+

Pedidos autorizados

+ + +
+ + diff --git a/integrations/sgf/operational/BootOverlay.php b/integrations/sgf/operational/BootOverlay.php new file mode 100644 index 0000000..03a0dac --- /dev/null +++ b/integrations/sgf/operational/BootOverlay.php @@ -0,0 +1,36 @@ +app->scoped(CompanyContext::class'; + $new = "if (is_file(base_path('packages/internal-requisitions/bootstrap.php'))) { require_once base_path('packages/internal-requisitions/bootstrap.php'); }\n ".$old; + $source = self::replace($source, $old, $new); + $source = self::replace($source, "public function boot(): void\n {", "public function boot(): void\n {\n if (is_file(base_path('packages/internal-requisitions/bootstrap.php'))) { \$this->loadRoutesFrom(base_path('packages/internal-requisitions/integrations/sgf/operational/routes.php')); }"); + } else { + $source = self::replace($source, "'purchasing'=>['purchasing_budget.', 'supplier_payables.']", "'purchasing'=>['purchasing_budget.', 'purchasing_requisitions.', 'supplier_payables.']"); + } + $result[$path] = ['before_sha256' => $hash, 'after_sha256' => hash('sha256', $source), 'contents' => $source]; + } + + return $result; + } + + private static function replace(string $source, string $old, string $new): string + { + if (substr_count($source, $old) !== 1) { + throw new \RuntimeException('Arranque/navegação do host divergiu; reconciliar antes de preparar.'); + } + + return str_replace($old, $new, $source); + } +} diff --git a/integrations/sgf/operational/BudgetOverlay.php b/integrations/sgf/operational/BudgetOverlay.php new file mode 100644 index 0000000..c58bdb9 --- /dev/null +++ b/integrations/sgf/operational/BudgetOverlay.php @@ -0,0 +1,21 @@ + ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source]]; + } +} diff --git a/integrations/sgf/operational/BudgetSchema.php b/integrations/sgf/operational/BudgetSchema.php new file mode 100644 index 0000000..19eb0ed --- /dev/null +++ b/integrations/sgf/operational/BudgetSchema.php @@ -0,0 +1,97 @@ +getSchemaBuilder(); + $scope = function (Blueprint $t): void { + $t->unsignedBigInteger('tenant_id'); + $t->unsignedBigInteger('company_id'); + $t->unsignedBigInteger('unit_id'); + $t->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete(); + }; + $schema->create('cb_centres', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->string('code'); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'code']); + }); + $schema->create('cb_origins', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('cost_center_id')->constrained('cb_centres'); + $t->string('required_kind'); + $t->string('reference')->unique(); + }); + $schema->create('cb_budgets', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('cost_center_id')->constrained('cb_centres'); + $t->integer('year'); + $t->string('kind')->default('operating'); + $t->bigInteger('amount_minor'); + $t->boolean('includes_tax'); + $t->text('reason'); + $t->foreignId('created_by')->constrained('users'); + $t->foreignId('approved_by')->nullable()->constrained('users'); + $t->timestamp('approved_at')->nullable(); + $t->timestamps(); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'cost_center_id', 'year']); + }); + $schema->create('cb_budget_entries', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('budget_id')->constrained('cb_budgets'); + $t->foreignId('requisition_id')->constrained('cb_origins'); + $t->foreignId('created_by')->constrained('users'); + $t->string('kind'); + $t->bigInteger('reserved_minor'); + $t->bigInteger('committed_minor'); + $t->timestamp('created_at'); + $t->unique(['requisition_id', 'kind']); + }); + $schema->create('cb_budget_consumptions', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('budget_id')->constrained('cb_budgets'); + // Only a synthetic source identifier, not a payable or invoice implementation. + $t->unsignedBigInteger('invoice_id')->unique(); + $t->foreignId('created_by')->constrained('users'); + $t->bigInteger('amount_minor'); + $t->timestamp('created_at'); + }); + $schema->create('cb_budget_reinforcements', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('budget_id')->constrained('cb_budgets'); + $t->foreignId('created_by')->constrained('users'); + $t->foreignId('reviewed_by')->nullable()->constrained('users'); + $t->string('reference'); + $t->string('payload_hash', 64); + $t->bigInteger('amount_minor'); + $t->text('reason'); + $t->string('status')->default('pending'); + $t->text('review_reason')->nullable(); + $t->timestamp('reviewed_at')->nullable(); + $t->timestamps(); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'reference']); + }); + $schema->create('cb_audit', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('actor_id')->constrained('users'); + $t->string('action'); + $t->unsignedBigInteger('origin_id')->nullable(); + $t->jsonb('payload'); + $t->timestamp('created_at'); + }); + + } +} diff --git a/integrations/sgf/operational/Composition.php b/integrations/sgf/operational/Composition.php new file mode 100644 index 0000000..506fb37 --- /dev/null +++ b/integrations/sgf/operational/Composition.php @@ -0,0 +1,23 @@ +middleware(RuntimeContext::class); + } + } + } +} diff --git a/integrations/sgf/operational/Installation.php b/integrations/sgf/operational/Installation.php new file mode 100644 index 0000000..78a39b7 --- /dev/null +++ b/integrations/sgf/operational/Installation.php @@ -0,0 +1,120 @@ +getDriverName() !== 'pgsql' || $db->selectOne('select current_database() as name')->name !== $db->getDatabaseName()) { + throw new \RuntimeException('Destino PostgreSQL não conferido.'); + } + + $server = $db->selectOne('select inet_server_addr()::text as address, inet_server_port() as port'); + + return hash('sha256', json_encode([$db->getDriverName(), $db->getConfig('host'), (string) $db->getConfig('port'), $db->getDatabaseName(), $server->address, $server->port], JSON_THROW_ON_ERROR)); + } + + public static function migrationHash(): string + { + return hash('sha256', file_get_contents(__DIR__.'/BudgetSchema.php').file_get_contents(__DIR__.'/budget-contract.json').file_get_contents(dirname(__DIR__).'/internal-requisitions/Schema.php')); + } + + public static function signature(ConnectionInterface $db, array $tables): string + { + sort($tables); + $bindings = implode(',', array_fill(0, count($tables), '?')); + + return hash('sha256', json_encode([ + $db->select("select table_name,column_name,data_type,character_maximum_length,is_nullable,ordinal_position from information_schema.columns where table_schema='public' and table_name in ($bindings) order by table_name,ordinal_position", $tables), + $db->select("select c.relname,k.conname,pg_get_constraintdef(k.oid) as definition from pg_constraint k join pg_class c on c.oid=k.conrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relname in ($bindings) order by c.relname,k.conname", $tables), + $db->select("select c.relname,t.tgname,pg_get_triggerdef(t.oid) as definition from pg_trigger t join pg_class c on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relname in ($bindings) and not t.tgisinternal order by c.relname,t.tgname", $tables), + ], JSON_THROW_ON_ERROR)); + } + + public static function tables(): array + { + return [...self::TABLES, 'permission_grants', 'ir_requests', 'ir_steps', 'ir_events', 'ir_commands', 'ir_policies', 'ir_policy_versions']; + } + + public static function migrate(ConnectionInterface $db, string $expectedTarget, string $backupHash, string $operator): array + { + if (! hash_equals(self::target($db), $expectedTarget) || ! preg_match('/^[a-f0-9]{64}$/D', $backupHash) || trim($operator) === '') { + throw new \RuntimeException('Destino, backup ou operador não conferidos.'); + } + + return $db->transaction(function () use ($db, $backupHash, $operator) { + $db->select("select pg_advisory_xact_lock(hashtext('tempo.internal-requisitions.install.v1'))"); + $schema = $db->getSchemaBuilder(); + if ($schema->hasTable('sgc_installations')) { + $old = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first(); + if (! $old || $old->revision !== 1 || ! hash_equals($old->migration_sha256, self::migrationHash()) || ! hash_equals($old->schema_signature, self::signature($db, self::tables()))) { + throw new \RuntimeException('Instalação/schema divergente; reconciliar sem sobrescrever.'); + } + + return ['prepared' => true, 'replayed' => true, 'activated' => false]; + } + foreach (['users', 'tenants', 'companies', 'units', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) { + if (! $schema->hasTable($table)) { + throw new \RuntimeException('Fundação de identidade ausente: '.$table); + } + } + $budgetTables = array_filter(self::TABLES, fn ($table) => $schema->hasTable($table)); + if ($budgetTables !== [] && count($budgetTables) !== count(self::TABLES)) { + throw new \RuntimeException('Schema orçamental parcial; não completar às cegas.'); + } + if ($budgetTables === []) { + BudgetSchema::create($db); + } + $contract = json_decode(file_get_contents(__DIR__.'/budget-contract.json'), true, 16, JSON_THROW_ON_ERROR); + if (! hash_equals($contract['schema_signature'], self::signature($db, self::TABLES))) { + throw new \RuntimeException('Schema orçamental incompatível com o contrato aceite.'); + } + $constraint = $db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'"); + if (! $constraint || ! preg_match('/^CHECK\s*\((.*)\)$/s', $constraint->definition, $parts)) { + throw new \RuntimeException('Catálogo de âmbito desconhecido.'); + } + $budgetRights = array_values(array_filter(AccessOverlay::RIGHTS, fn ($right) => str_starts_with($right, 'purchasing_budget.'))); + if (array_filter($budgetRights, fn ($right) => ! str_contains($constraint->definition, "'".$right."'")) !== []) { + $literals = implode(',', array_map(fn ($right) => $db->getPdo()->quote($right).'::text', $budgetRights)); + $db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check'); + $db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$parts[1]}) OR (unit_id IS NOT NULL AND permission::text IN ($literals)))"); + } + RequisitionSchema::install($db); + $schema->create('sgc_installations', function (Blueprint $table) { + $table->string('module')->primary(); + $table->integer('revision'); + $table->string('schema_signature', 64); + $table->string('migration_sha256', 64); + $table->string('backup_sha256', 64); + $table->string('operator'); + $table->string('database_operator'); + $table->timestampTz('prepared_at'); + }); + $db->table('sgc_installations')->insert(['module' => 'internal-requisitions', 'revision' => 1, 'schema_signature' => self::signature($db, self::tables()), 'migration_sha256' => self::migrationHash(), 'backup_sha256' => $backupHash, 'operator' => $operator, 'database_operator' => $db->selectOne('select current_user as name')->name, 'prepared_at' => now()]); + $db->unprepared("CREATE FUNCTION sgc_installations_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve installation history'; END; \$\$; CREATE TRIGGER sgc_installations_immutable BEFORE UPDATE OR DELETE ON sgc_installations FOR EACH ROW EXECUTE FUNCTION sgc_installations_immutable();"); + + return ['prepared' => true, 'replayed' => false, 'activated' => false]; + }); + } + + public static function assertBudgetReady(ConnectionInterface $db): void + { + abort_unless(config('purchasing-operational.enabled') === true, 403); + abort_unless(hash_equals((string) config('purchasing-operational.target', ''), self::target($db)), 403, 'Destino operacional divergente.'); + $context = app(CompanyContext::class); + $scope = $context->identifiers() + ['unit_id' => $context->unitId()]; + abort_unless($scope['unit_id'] !== null && in_array(implode(':', $scope), config('purchasing-operational.scopes', []), true), 403, 'Opção interna não configurada para esta unidade.'); + abort_unless($db->getSchemaBuilder()->hasTable('sgc_installations'), 503, 'Schema operacional não preparado.'); + $row = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first(); + abort_unless($row && $row->revision === 1 && hash_equals($row->migration_sha256, self::migrationHash()) && hash_equals($row->schema_signature, self::signature($db, self::tables())), 503, 'Schema operacional não preparado.'); + } +} diff --git a/integrations/sgf/operational/RuntimeContext.php b/integrations/sgf/operational/RuntimeContext.php new file mode 100644 index 0000000..b9a7d76 --- /dev/null +++ b/integrations/sgf/operational/RuntimeContext.php @@ -0,0 +1,34 @@ +identifiers(); + $policy = config('modules.companies.'.$scope['tenant_id'].':'.$scope['company_id'], []); + $contracted = $policy['contracted'] ?? config('modules.contracted', []); + abort_unless(in_array('purchasing', $contracted, true) && in_array('purchasing', $policy['enabled'] ?? $contracted, true), 403); + $initialisation = $request->is('purchasing/internal-requisitions/access-initialisation', 'api/internal-requisitions/access-initialisation'); + if (! $initialisation) { + abort_unless($request->session()->get('company_context.module_id') === 'purchasing', 409, 'Seleccione o módulo Compras.'); + abort_unless(in_array('purchasing', array_column(app(ModuleNavigation::class)->available($context), 'id'), true), 403); + } + if ($request->is('api/*')) { + $token = (string) $request->session()->get('company_context.token', ''); + abort_unless($token !== '' && hash_equals($token, (string) $request->header('X-SGF-Context')), 409); + } + + return $next($request); + } +} diff --git a/integrations/sgf/operational/bootstrap.php b/integrations/sgf/operational/bootstrap.php new file mode 100644 index 0000000..fa2e4ce --- /dev/null +++ b/integrations/sgf/operational/bootstrap.php @@ -0,0 +1,7 @@ +/bootstrap.php, without a source-project dependency. +require_once __DIR__.'/integrations/sgf/operational/bootstrap.php'; +require_once __DIR__.'/integrations/sgf/operational/RuntimeContext.php'; +require_once __DIR__.'/integrations/sgf/operational/Composition.php'; diff --git a/integrations/sgf/operational/routes.php b/integrations/sgf/operational/routes.php new file mode 100644 index 0000000..5f505a5 --- /dev/null +++ b/integrations/sgf/operational/routes.php @@ -0,0 +1,5 @@ +isLink(), 'Links não são permitidos no pacote.'); + if (! $file->isFile()) { + continue; + } + $path = str_replace('\\', '/', substr($file->getPathname(), strlen($source) + 1)); + self::fail(self::path($path), 'Ficheiro fora do contrato do pacote: '.$path); + $size += $file->getSize(); + self::fail($size <= self::LIMIT, 'Pacote demasiado grande.'); + $contents[$path] = file_get_contents($file->getPathname()); + $files[$path] = hash('sha256', $contents[$path]); + } + ksort($files); + self::fail(isset($files['bootstrap.php'], $files['module.json']) && count($files) <= 20000, 'Pacote incompleto.'); + $digest = hash('sha256', self::encode($files)); + self::fail(is_dir($channel) || mkdir($channel, 0700, true), 'Canal de distribuição indisponível.'); + $lock = fopen($channel.'/.publish.lock', 'c'); + self::fail($lock !== false && flock($lock, LOCK_EX), 'Publicação ocupada.'); + try { + $catalog = is_file($channel.'/catalog.json') ? self::json($channel.'/catalog.json') : ['schema' => 1, 'releases' => []]; + self::fail(($catalog['schema'] ?? null) === 1 && is_array($catalog['releases'] ?? null), 'Canal incompatível.'); + $last = $catalog['releases'] === [] ? null : $catalog['releases'][array_key_last($catalog['releases'])]; + if ($last !== null && ($last['source_sha256'] ?? null) === $digest) { + self::inspect($channel.'/'.$last['archive'], $last['manifest_sha256']); + + return $last + ['changed' => false]; + } + $version = $last === null ? '0.1.0' : implode('.', [...array_slice(explode('.', $last['version']), 0, 2), ((int) explode('.', $last['version'])[2]) + 1]); + self::version($version); + $manifest = ['schema' => 1, 'package' => 'tempo/purchasing', 'version' => $version, + 'source_sha256' => $digest, 'module' => $module, 'files' => $files]; + $manifestText = self::encode($manifest); + $filename = 'purchasing-'.$version.'.zip'; + self::fail(! file_exists($channel.'/'.$filename), 'Versão já existe; não será sobrescrita.'); + $temporary = $channel.'/.publish-'.bin2hex(random_bytes(8)).'.zip'; + $zip = new ZipArchive; + self::fail($zip->open($temporary, ZipArchive::CREATE | ZipArchive::EXCL) === true, 'Não foi possível criar o pacote.'); + foreach ($files as $path => $hash) { + self::fail($zip->addFromString('payload/'.$path, $contents[$path]), 'Falha ao empacotar código.'); + } + $zip->addFromString('manifest.json', $manifestText); + self::fail($zip->close(), 'Falha ao concluir o pacote.'); + $entry = ['version' => $version, 'archive' => $filename, 'source_sha256' => $digest, 'manifest_sha256' => hash('sha256', $manifestText)]; + self::inspect($temporary, $entry['manifest_sha256']); + self::fail(rename($temporary, $channel.'/'.$filename), 'Falha ao publicar versão.'); + $catalog['releases'][] = $entry; + self::write($channel.'/catalog.next.json', self::encode($catalog)); + self::fail(rename($channel.'/catalog.next.json', $channel.'/catalog.json'), 'Falha ao publicar catálogo.'); + + return $entry + ['changed' => true]; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + /** Validate every archive byte and reject undeclared files before writing anywhere. */ + public static function inspect(string $archive, string $expectedManifest): array + { + self::fail(preg_match('/^[a-f0-9]{64}$/D', $expectedManifest) === 1 && is_file($archive) && filesize($archive) <= self::LIMIT, 'Pacote ou referência de confiança inválidos.'); + $zip = new ZipArchive; + self::fail($zip->open($archive, ZipArchive::RDONLY) === true, 'Pacote ilegível.'); + try { + $metadata = $zip->statName('manifest.json'); + self::fail(is_array($metadata) && $metadata['size'] < 1048576 && $zip->numFiles < 20001, 'Manifesto ou arquivo demasiado grande.'); + $raw = $zip->getFromName('manifest.json'); + self::fail(is_string($raw) && strlen($raw) < 1048576 && hash_equals($expectedManifest, hash('sha256', $raw)), 'Manifesto diferente da referência aprovada.'); + $manifest = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); + self::fail(($manifest['schema'] ?? null) === 1 && ($manifest['package'] ?? '') === 'tempo/purchasing' + && ($manifest['module']['host_contract'] ?? null) === 1 && ($manifest['module']['database_changes'] ?? null) === false + && ($manifest['module']['commercial_permissions'] ?? null) === false, 'Contrato do pacote incompatível.'); + self::version($manifest['version']); + self::fail(version_compare(PHP_VERSION, $manifest['module']['minimum_php'], '>='), 'Versão PHP incompatível.'); + $files = $manifest['files'] ?? []; + self::fail(is_array($files) && isset($files['bootstrap.php'], $files['module.json']) && count($files) < 20000, 'Pacote incompleto.'); + $seen = []; + $contents = []; + $size = 0; + for ($index = 0; $index < $zip->numFiles; $index++) { + $stat = $zip->statIndex($index); + $name = $stat['name']; + self::fail(! isset($seen[$name]), 'Entrada duplicada no pacote.'); + $seen[$name] = true; + $size += $stat['size']; + self::fail($size <= self::LIMIT, 'Pacote expandido demasiado grande.'); + if ($name === 'manifest.json') { + continue; + } + self::fail(str_starts_with($name, 'payload/'), 'Entrada inesperada no pacote.'); + $path = substr($name, 8); + self::fail(self::path($path) && isset($files[$path]), 'Caminho fora do pacote.'); + $value = $zip->getFromIndex($index); + self::fail(is_string($value) && hash_equals($files[$path], hash('sha256', $value)), 'Código divergente do manifesto.'); + $contents[$path] = $value; + } + self::fail(count($contents) === count($files), 'Faltam ficheiros no pacote.'); + $sorted = $files; + ksort($sorted); + self::fail(hash_equals($manifest['source_sha256'], hash('sha256', self::encode($sorted))), 'Identidade da origem inválida.'); + self::fail(json_decode($contents['module.json'], true, 32, JSON_THROW_ON_ERROR) === $manifest['module'], 'Metadados do módulo divergentes.'); + + return ['manifest' => $manifest, 'manifest_text' => $raw, 'contents' => $contents]; + } finally { + $zip->close(); + } + } + + /** Read the installed pointer and verify its immutable source files. */ + public static function current(string $host): ?array + { + $base = rtrim($host, '/\\').'/packages/purchasing'; + if (! is_file($base.'/current.json')) { + return null; + } + + return self::verifiedPointer($host, self::json($base.'/current.json')); + } + + private static function verifiedPointer(string $host, array $pointer): array + { + $base = rtrim($host, '/\\').'/packages/purchasing'; + self::version($pointer['version']); + $release = $base.'/releases/'.$pointer['version']; + $realHost = str_replace('\\', '/', realpath($host) ?: ''); + $realRelease = str_replace('\\', '/', realpath($release) ?: ''); + self::fail($realHost !== '' && str_starts_with(strtolower($realRelease), strtolower($realHost).'/packages/purchasing/releases/'), 'Caminho de versão fora da instalação.'); + $manifest = self::json($release.'/manifest.json'); + self::fail(($manifest['version'] ?? '') === $pointer['version'] && ($manifest['package'] ?? '') === 'tempo/purchasing', 'Identidade da versão instalada inválida.'); + self::fail(hash_equals($pointer['manifest_sha256'], hash_file('sha256', $release.'/manifest.json')), 'Versão instalada diverge da referência.'); + foreach ($manifest['files'] as $path => $hash) { + $realFile = str_replace('\\', '/', realpath($release.'/'.$path) ?: ''); + self::fail(self::path($path) && ! is_link($release.'/'.$path) && is_file($release.'/'.$path) + && str_starts_with(strtolower($realFile), strtolower($realRelease).'/') + && hash_equals($hash, hash_file('sha256', $release.'/'.$path)), 'Versão instalada foi alterada: '.$path); + } + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($release, \FilesystemIterator::SKIP_DOTS)) as $file) { + self::fail(! $file->isLink(), 'Link inesperado na versão instalada.'); + if ($file->isFile()) { + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($release) + 1)); + self::fail($relative === 'manifest.json' || isset($manifest['files'][$relative]), 'Ficheiro não declarado na versão instalada.'); + } + } + + return $pointer + ['path' => $release, 'capabilities' => $manifest['module']['capabilities']]; + } + + private static function activate(string $base, ?array $before, array $pointer, string $action): void + { + $pointer['audit_id'] = bin2hex(random_bytes(12)); + self::fail(! is_link($base.'/history') && (is_dir($base.'/history') || mkdir($base.'/history', 0700)), 'Histórico indisponível.'); + self::write($base.'/history/'.$pointer['audit_id'].'.json', self::encode(['action' => $action, 'at' => gmdate('c'), 'before' => $before, 'after' => $pointer, 'database_changed' => false])); + self::write($base.'/current.next.json', self::encode($pointer)); + self::fail(rename($base.'/current.next.json', $base.'/current.json'), 'Falha ao activar a versão; versão anterior preservada.'); + } + + /** Explicit rollback only to the previous verified code release; never rolls back data. */ + public static function rollback(string $host): array + { + $base = rtrim($host, '/\\').'/packages/purchasing'; + self::fail(is_dir($base) && ! is_link($base) && ! is_link(rtrim($host, '/\\').'/packages'), 'Módulo não instalado ou caminho inválido.'); + $lock = fopen($base.'/.update.lock', 'c'); + self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.'); + try { + $current = self::current($host); + self::fail($current !== null && preg_match('/^[a-f0-9]{24}$/D', $current['audit_id']) === 1, 'Sem versão anterior.'); + $history = self::json($base.'/history/'.$current['audit_id'].'.json'); + $previous = $history['before'] ?? null; + self::fail(is_array($previous), 'Sem versão anterior.'); + $verified = self::verifiedPointer($host, $previous); + $pointer = array_intersect_key($verified, array_flip(['version', 'manifest_sha256', 'source_sha256'])); + $pointer['previous'] = $current['version']; + self::activate($base, $current, $pointer, 'rollback_code_release'); + + return self::current($host); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + /** Immutable version installation + atomic pointer. No migrations, seeds or permissions. */ + public static function install(string $host, string $archive, string $expectedManifest): array + { + $checked = self::inspect($archive, $expectedManifest); + self::fail(is_dir($host) && ! is_link($host), 'Instalação de destino inexistente.'); + $host = realpath($host); + self::fail((self::json($host.'/composer.json')['name'] ?? '') === 'tempo/sgf-tempo', 'Destino não é uma instalação SGF.'); + self::fail(! is_link($host.'/packages'), 'O directório de pacotes não pode redireccionar a instalação.'); + $base = $host.'/packages/purchasing'; + self::fail(! is_link($base) && (is_dir($base) || mkdir($base, 0700, true)), 'Directório de módulo inválido.'); + $lock = fopen($base.'/.update.lock', 'c'); + self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.'); + try { + $current = self::current($host); + $version = $checked['manifest']['version']; + if ($current !== null && $current['version'] === $version) { + self::fail(hash_equals($current['manifest_sha256'], $expectedManifest), 'Tentativa de substituir uma versão imutável.'); + + return $current + ['changed' => false]; + } + self::fail($current === null || version_compare($version, $current['version'], '>'), 'Actualização não permite downgrade implícito.'); + $releases = $base.'/releases'; + self::fail(! is_link($releases) && (is_dir($releases) || mkdir($releases, 0700)), 'Directório de versões inválido.'); + $destination = $releases.'/'.$version; + if (is_dir($destination)) { + $pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null]; + self::verifiedPointer($host, $pointer); + self::activate($base, $current, $pointer, 'reactivate_verified_code_release'); + + return self::current($host) + ['changed' => true]; + } + self::fail(! file_exists($destination), 'Destino de versão inválido.'); + $stage = $releases.'/.stage-'.bin2hex(random_bytes(8)); + self::fail(mkdir($stage, 0700), 'Preparação da versão falhou.'); + foreach ($checked['contents'] as $path => $content) { + $directory = dirname($stage.'/'.$path); + self::fail(is_dir($directory) || mkdir($directory, 0700, true), 'Preparação do código falhou.'); + self::write($stage.'/'.$path, $content); + } + self::write($stage.'/manifest.json', $checked['manifest_text']); + self::fail(rename($stage, $destination), 'Falha ao fixar a versão preparada.'); + $pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null]; + self::verifiedPointer($host, $pointer); + self::activate($base, $current, $pointer, 'activate_code_release'); + + return self::current($host) + ['changed' => true]; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } +} diff --git a/packages/module-updates/purchasing-bootstrap.php b/packages/module-updates/purchasing-bootstrap.php new file mode 100644 index 0000000..4f6a14a --- /dev/null +++ b/packages/module-updates/purchasing-bootstrap.php @@ -0,0 +1,12 @@ + `. Executa testes na origem, prova a fotografia candidata, publica uma nova versão apenas se houver alterações e usa o PHP fixado pelo SGF para conferir a candidata antes da activação. Repetir sem melhorias não reinstala código. Clientes recebem o ZIP e a referência SHA-256 por canal de distribuição de confiança e não precisam do checkout de Compras. + +Este contrato não executa migrações nem substitui dependências, dados, acessos ou configuração empresarial. Pacotes que peçam alterações de base de dados são recusados até existir um contrato de migração ensaiado. Versões de código anteriores permanecem disponíveis para reversão explícita. A activação de módulos adquiridos e as permissões continuam sob a configuração e os mecanismos de acesso da instalação. diff --git a/packages/purchasing/bootstrap.php b/packages/purchasing/bootstrap.php new file mode 100644 index 0000000..241df17 --- /dev/null +++ b/packages/purchasing/bootstrap.php @@ -0,0 +1,8 @@ + $record + * @return array|null + */ + public static function nextStep(array $record, int $actor): ?array + { + if ($record['status'] !== 'pending' || $record['requester_id'] === $actor) { + return null; + } + $next = null; + foreach ($record['steps'] as $step) { + if (($step['approver_id'] ?? null) === $actor) { + return null; + } + if ($next === null && $step['decision'] === 'pending') { + $next = $step; + } + } + + return $next; + } + + /** @return array{subtotal_minor:int,tax_minor:int,total_minor:int,unit_price_minor:int} */ + public static function line(string $quantity, string $price, string $taxRate): array + { + $minor = BigDecimal::of($price)->multipliedBy(100)->toScale(0)->toInt(); + $subtotal = BigDecimal::of($quantity)->multipliedBy($minor)->toScale(0, RoundingMode::HalfUp)->toInt(); + $tax = BigDecimal::of($subtotal)->multipliedBy($taxRate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt(); + + return ['unit_price_minor' => $minor, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax]; + } + + /** + * @param array $record + * @param array $policy + * @return list + */ + public static function roles(array $record, array $policy): array + { + $amount = $policy['approval_includes_tax'] ? $record['total_minor'] : $record['subtotal_minor']; + $roles = ['department_head']; + if ($amount > $policy['first_limit_minor']) { + $roles[] = 'procurement'; + } + if ($amount > $policy['second_limit_minor'] || $record['has_equipment']) { + $roles[] = 'finance'; + } + + return $roles; + } + + private static function canonical(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + if (! array_is_list($value)) { + ksort($value); + } + + return array_map(self::canonical(...), $value); + } + + /** + * @param array $input + * @return array + */ + public function command(string $action, ?int $id, ?int $version, ?string $key, array $input = []): array + { + if ($key !== null && ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/iD', $key)) { + throw new RequisitionFailure('Chave de operação inválida.', 422); + } + $hash = hash('sha256', json_encode(self::canonical([$action, $id, $version, $input]), JSON_THROW_ON_ERROR)); + + return $this->port->atomic(function () use ($action, $id, $version, $key, $hash, $input) { + $record = $id === null ? null : $this->port->load($id); + $this->port->authorize($action, $record); + if ($key !== null && ($old = $this->port->replay($key, $hash)) !== null) { + $current = $this->port->load($old['id']); + $this->port->authorize('view', $current); + if (isset($old['operation_role'])) { + $this->port->authorize('step:'.$old['operation_role'], $current); + } + + return $old + ['replayed' => true]; + } + if ($record !== null && $version !== null && $record['version'] !== $version) { + throw new RequisitionFailure('A requisição mudou. Actualize antes de continuar.'); + } + $role = null; + if ($action === 'save') { + if ($record !== null && ($record['status'] !== 'draft' || $record['requester_id'] !== $this->port->actorId())) { + throw new RequisitionFailure('Rascunho não autorizado.', 403); + } + $result = $this->port->draft($input, $id); + } elseif ($action === 'submit') { + if ($record === null || $record['status'] !== 'draft' || $record['requester_id'] !== $this->port->actorId()) { + throw new RequisitionFailure('Submissão não autorizada.', 403); + } + $policy = $this->port->policy($record); + $this->port->reserve($record); + $result = $this->port->submitted($record, $policy, self::roles($record, $policy)); + } elseif ($action === 'decide') { + $actor = $this->port->actorId(); + if ($record === null || $record['status'] !== 'pending' || $record['requester_id'] === $actor) { + throw new RequisitionFailure('A decisão exige outra identidade autorizada.', 403); + } + $step = self::nextStep($record, $actor); + if ($step === null) { + throw new RequisitionFailure('A decisão exige outra identidade para cada etapa.', 403); + } + if (! in_array($input['decision'] ?? '', ['approved', 'rejected'], true)) { + throw new RequisitionFailure('Decisão inválida.', 422); + } + $role = $step['role']; + $this->port->authorize('step:'.$role, $record); + $status = $input['decision'] === 'rejected' ? 'rejected' : (count(array_filter($record['steps'], fn ($s) => $s['decision'] === 'pending')) > 1 ? 'pending' : 'approved'); + $result = $this->port->decided($record, $step, $input['decision'], $input['comment'] ?? null, $status); + if ($status === 'rejected') { + $this->port->release($record); + } + } else { + throw new RequisitionFailure('Operação desconhecida.', 404); + } + if ($role !== null) { + $result['operation_role'] = $role; + } + if ($key !== null) { + $this->port->remember($key, $hash, $result); + } + + return $result; + }); + } + + /** @return array */ + public function view(int $id): array + { + return $this->port->atomic(function () use ($id) { + $record = $this->port->load($id); + $this->port->authorize('view', $record); + + return $record; + }); + } +} diff --git a/packages/purchasing/src/ProposalScoring.php b/packages/purchasing/src/ProposalScoring.php new file mode 100644 index 0000000..fcb1bc0 --- /dev/null +++ b/packages/purchasing/src/ProposalScoring.php @@ -0,0 +1,74 @@ +> $rows + * @return array{scores:array,top_ids:list} + */ + public function rank(array $rows, int $minimumEvaluators): array + { + if ($minimumEvaluators < 1) { + throw new InvalidArgumentException('O mínimo de avaliadores deve ser positivo.'); + } + $minimum = null; + $days = null; + $ids = []; + foreach ($rows as $row) { + if (! is_int($row['id']) || isset($ids[$row['id']])) { + throw new InvalidArgumentException('As propostas devem ter referências distintas.'); + } + $ids[$row['id']] = true; + if (! $row['eligible']) { + continue; + } + $weights = $row['weights']; + $keys = ['price', 'quality', 'delivery', 'history', 'payment']; + if (array_diff($keys, array_keys($weights)) !== [] || count($weights) !== 5 || count(array_filter($weights, fn ($weight) => is_int($weight) && $weight >= 0 && $weight <= 100)) !== 5 + || array_sum($weights) !== 100 || ! is_int($row['subtotal_minor']) || $row['subtotal_minor'] < 1 || ! is_int($row['delivery_days']) || $row['delivery_days'] < 1 + || $row['evaluation_count'] !== count($row['qualified_evaluations'])) { + throw new InvalidArgumentException('Base de pontuação inconsistente.'); + } + $minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']); + $days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']); + } + $scores = []; + $topIds = []; + $best = null; + foreach ($rows as $row) { + if (! $row['eligible'] || $row['evaluation_count'] < $minimumEvaluators) { + continue; + } + $weights = $row['weights']; + $score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price']) + ->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery'])); + foreach (['quality', 'history', 'payment'] as $criterion) { + $sum = BigRational::zero(); + foreach ($row['qualified_evaluations'] as $evaluation) { + $value = BigRational::of($evaluation[$criterion]); + if ($value->isNegative() || $value->isGreaterThan(10)) { + throw new InvalidArgumentException('Avaliação fora da escala de comparação.'); + } + $sum = $sum->plus($value); + } + $score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion])); + } + $minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt(); + $scores[$row['id']] = $minor; + if ($best === null || $minor > $best) { + $best = $minor; + $topIds = [$row['id']]; + } elseif ($minor === $best) { + $topIds[] = $row['id']; + } + } + + return ['scores' => $scores, 'top_ids' => $topIds]; + } +} diff --git a/packages/purchasing/src/QuotationPricing.php b/packages/purchasing/src/QuotationPricing.php new file mode 100644 index 0000000..7deb8fa --- /dev/null +++ b/packages/purchasing/src/QuotationPricing.php @@ -0,0 +1,33 @@ +multipliedBy(100)->toInt(); + $discount = BigDecimal::of($value); + if ($gross < 1 || $discount->isNegative() || ! in_array($type, ['none', 'percent', 'amount'], true) + || ($type === 'none' && ! $discount->isZero()) || ($type === 'percent' && $discount->isGreaterThan(100))) { + throw new InvalidArgumentException('Indique um desconto unitário válido: percentagem ou valor em MZN, antes do IVA.'); + } + $reduction = match ($type) { + 'percent' => BigDecimal::of($gross)->multipliedBy($discount)->dividedBy(100, 4), + 'amount' => $discount->multipliedBy(100), + default => BigDecimal::zero(), + }; + $net = BigDecimal::of($gross)->minus($reduction)->toScale(0, RoundingMode::HalfUp)->toInt(); + if ($net < 1) { + throw new InvalidArgumentException('O preço unitário após desconto deve ser pelo menos 0,01 MZN.'); + } + + return ['gross_unit_minor' => $gross, 'discount_type' => $type, 'discount_value' => (string) $discount->toScale(2), 'net_unit_minor' => $net]; + } +} diff --git a/packages/purchasing/src/ReplenishmentProjection.php b/packages/purchasing/src/ReplenishmentProjection.php new file mode 100644 index 0000000..2b3f78f --- /dev/null +++ b/packages/purchasing/src/ReplenishmentProjection.php @@ -0,0 +1,115 @@ + $policy + * @param list $reservations + * @param list $receipts + * @param list $fulfilledNeeds + * @param list|null $lotBuckets + * @return array + */ + public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array + { + $day = CarbonImmutable::parse($start)->startOfDay(); + $daily = $policy['daily_milli'] ?? null; + $rate = $policy['daily_rate'] ?? null; + if ($rate !== null) { + if (! is_int($rate['numerator_milli']) || $rate['numerator_milli'] < 0 || ! is_int($rate['denominator_days']) || $rate['denominator_days'] < 1) { + throw new \InvalidArgumentException('A média histórica exige numerador não negativo e denominador positivo.'); + } + $daily = 0; + } + if ($daily === null && $reservations === []) { + return ['available' => false, 'reason' => 'Previsão indisponível: configure consumo manual ou histórico validado.']; + } + $knownOnly = $daily === null; + $daily ??= 0; + $balance = $physical; + $risk = null; + $shortage = null; + $before = null; + $series = []; + $safety = $policy['safety_milli']; + if ($balance <= $safety) { + $risk = $day->toDateString(); + $before = $balance; + } + for ($offset = 0; $offset < $policy['horizon_days']; $offset++) { + $date = $day->addDays($offset)->toDateString(); + $expired = 0; + if ($lotBuckets !== null) { + foreach ($lotBuckets as &$bucket) { + if ($bucket['last_usable_on'] !== null && $bucket['last_usable_on'] < $date) { + $expired += $bucket['quantity']; + $bucket['quantity'] = 0; + } + } + unset($bucket); + } + $incoming = array_sum(array_column(array_filter($receipts, fn ($r) => $r['confirmed'] && $r['date'] === $date), 'quantity')); + $known = array_filter($reservations, fn ($r) => $r['date'] === $date); + $substitutes = array_sum(array_column(array_filter($known, fn ($r) => ! ($r['additional'] ?? false)), 'quantity')); + $additional = array_sum(array_column(array_filter($known, fn ($r) => $r['additional'] ?? false), 'quantity')); + $satisfied = array_sum(array_column(array_filter($fulfilledNeeds, fn ($n) => $n['date'] === $date), 'quantity')); + $expected = $daily; + if ($rate !== null) { + $cumulative = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset + 1)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt(); + $previous = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt(); + $expected = $cumulative - $previous; + } + $residualForecast = max(0, $expected - $satisfied - ($offset === 0 ? $consumedToday : 0)); + $demand = max($residualForecast, $substitutes) + $additional; + $balance += $incoming - $demand - $expired; + if ($lotBuckets !== null) { + $remaining = $demand; + foreach ($lotBuckets as &$bucket) { + $take = min($remaining, $bucket['quantity']); + $bucket['quantity'] -= $take; + $remaining -= $take; + } + unset($bucket); + } + if ($risk === null && $balance <= $safety) { + $risk = $date; + $before = $balance; + } + if ($shortage === null && $balance < 0) { + $shortage = $date; + } + $series[] = ['date' => $date, 'balance_milli' => $balance, 'incoming_milli' => $incoming, 'demand_milli' => $demand, ...($lotBuckets !== null ? ['expired_milli' => $expired] : [])]; + } + $base = $before === null ? 0 : max(0, $policy['target_milli'] - $before); + $quantity = $base === 0 ? 0 : max($base, $policy['minimum_milli']); + $multiple = $policy['pack_milli']; + $quantity = $quantity === 0 ? 0 : intdiv($quantity + $multiple - 1, $multiple) * $multiple; + $excess = $before === null ? 0 : max(0, $before + $quantity - $policy['maximum_milli']); + $futureExcess = 0; + if ($risk !== null && $quantity > 0) { + foreach ($series as $point) { + if ($point['date'] >= $risk) { + $futureExcess = max($futureExcess, $point['balance_milli'] + $quantity - $policy['maximum_milli']); + } + } + } + + $mean = $rate === null ? $daily : (string) BigDecimal::of($rate['numerator_milli'])->dividedBy($rate['denominator_days'], 6, RoundingMode::HalfUp); + $reorder = $rate === null ? $daily * $policy['lead_days'] + $safety : BigDecimal::of($rate['numerator_milli'])->multipliedBy($policy['lead_days'])->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->plus($safety)->toInt(); + + return ['available' => true, 'method' => $knownOnly ? 'known_needs_only' : ($rate === null ? 'manual_calendar_days' : 'historical_mean_calendar_days'), 'demand_complete' => ! $knownOnly, 'physical_milli' => $physical, + 'daily_milli' => $mean, 'reorder_point_milli' => $reorder, + 'risk_date' => $risk, 'shortage_date' => $shortage, + 'order_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'])->toDateString(), + 'request_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'] + $policy['approval_days'])->toDateString(), + 'base_milli' => $base, 'quantity_milli' => $quantity, 'excess_milli' => $excess, + 'future_excess_milli' => $futureExcess, 'requires_exception' => $excess > 0 || $futureExcess > 0, 'series' => $series, + 'convention' => 'Stock inicial na abertura; entradas confirmadas de manhã; consumo e nova reposição no fim do dia.']; + } +} diff --git a/packages/purchasing/src/RequisitionFailure.php b/packages/purchasing/src/RequisitionFailure.php new file mode 100644 index 0000000..6ceec27 --- /dev/null +++ b/packages/purchasing/src/RequisitionFailure.php @@ -0,0 +1,11 @@ +|null $record */ + public function authorize(string $action, ?array $record): void; + + /** @return array */ + public function load(int $id): array; + + /** + * @param array $data + * @return array + */ + public function draft(array $data, ?int $id): array; + + /** + * @param array $record + * @return array + */ + public function policy(array $record): array; + + /** + * @param array $record + * @return array|null + */ + public function reserve(array $record): ?array; + + /** + * @param array $record + * @param array $policy + * @param list $roles + * @return array + */ + public function submitted(array $record, array $policy, array $roles): array; + + /** + * @param array $record + * @param array $step + * @return array + */ + public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array; + + /** @param array $record */ + public function release(array $record): void; + + /** @return array|null */ + public function replay(string $key, string $hash): ?array; + + /** @param array $result */ + public function remember(string $key, string $hash, array $result): void; +} diff --git a/resources/js/components/inventory-event-actions.tsx b/resources/js/components/inventory-event-actions.tsx new file mode 100644 index 0000000..793dcc0 --- /dev/null +++ b/resources/js/components/inventory-event-actions.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { useForm } from '@inertiajs/react'; +import { FormErrors, controlClass } from '@/components/procurement'; +import { Button } from '@/components/ui/button'; + +type Choice = { value: string; label: string }; +type Props = { + route: string; + record: { record_key: string; version: number }; + choices: Choice[]; + today?: string; + data?: Record; +}; + +function Confirm({ props, choice }: { props: Props; choice: Choice }) { + const form = useForm({ + operation_key: crypto.randomUUID(), + record_key: props.record.record_key, + version: props.record.version, + action: choice.value, + reason: '', + data: { + ...props.data, + ...(choice.value === 'execute' + ? { event_on: props.today ?? '', evidence: '' } + : {}), + }, + }); + return ( +
{ + e.preventDefault(); + form.post(props.route); + }} + > + +