kind === 'stock_event' && app(InventoryReviewParticipation::class)->executionSourceCurrent($record, fn (array $data) => $this->basis($data)); } /** Participation only: source, stock and physical evidence are revalidated by save. */ public function canAct(User $actor, SupplyRecord $record, string $action): bool { return app(InventoryReviewParticipation::class)->canAct($actor, $record, 'stock_event', $action, fn (string $capability) => $this->allows($actor, $capability)); } public function allows(User $actor, string $capability): bool { return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($capability) { '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' && app(InventoryReviewParticipation::class)->independentExecutor($actor, $old), 'action', 'A saída exige aprovação prévia e operador diferente de quem aprovou.'); $event = Validator::make($v['data'], ['event_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:10|max:3000'])->validate(); $this->check($event['event_on'] >= $old->created_at->toDateString(), 'data.event_on', 'A saída não pode anteceder a aprovação.'); $current = $this->basis($data); $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(app(InventoryReviewParticipation::class)->independentReviewer($actor, $old), 'action', 'A decisão exige pessoa independente de todos os preparadores.'); $this->check($v['action'] === 'return' ? in_array($old->state, ['draft', 'returned', 'approved'], true) : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível com a decisão.'); $state = $v['action'] === 'approve' ? 'approved' : 'returned'; if ($state === 'approved') { $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]); } } }