$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]); } } }