hasFunctions($functions) && ! app(OrganizationAccess::class)->maintenanceOnly($actor); } public function independentReviewer(User $actor, SupplyRecord $record): bool { return $actor->tenant_id === $record->tenant_id && $this->allows($actor, 'supply_review') && ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true); } /** Eligibility to confer, not a promise that changed sources permit approval. */ public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool { if (! in_array($record->kind, ['agreement', 'policy'], true) || ! in_array($action, ['approve', 'return', 'suspend'], true) || ! $this->independentReviewer($actor, $record)) { return false; } $latest = $this->latest($record->kind)->where('record_key', $record->record_key)->first(); return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true)); } /** @return Builder */ public function latest(string $kind) { 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($this->independentReviewer($actor, $old), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.'); $this->check($action === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.'); $state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$action]; } else { $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 ($order !== null) { app(OrderCancellationWorkflow::class)->assertOperational($order); } 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]); } } }