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