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