hasFunctions($permission === 'inventory_lot_read' ? [$permission, 'admin'] : [$permission]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor); } public function setup(int $item, int $warehouse): ?SupplyRecord { if (! Schema::hasTable('inventory_lot_allocations')) { return null; } return $this->activeSetups()->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first(); } /** Active versions remain available while a proposed revision awaits review. * @return Builder */ public function activeSetups(): Builder { return SupplyRecord::query()->where('kind', 'lot_setup')->where('state', 'active')->whereIn('id', SupplyRecord::query()->selectRaw('MAX(id)')->where('kind', 'lot_setup')->where('state', 'active')->groupBy('stock_item_id', 'warehouse_id')); } /** @return array{setup_id:int,physical:array{quantity:int,version:int},reconciled:bool,usable_milli:int,blocked_milli:int,lots:list>}|null */ public function context(int $item, int $warehouse): ?array { // Read book and partitions under the same lock used by every stock writer. $reader = auth()->user(); if (DB::transactionLevel() === 0 && $reader instanceof User) { return DB::transaction(function () use ($item, $warehouse, $reader) { app(ConflictGuard::class)->lockCompany($reader); return $this->context($item, $warehouse); }); } $setup = $this->setup($item, $warehouse); if ($setup === null) { return null; } $physical = app(StockWorkflow::class)->balance($item, $warehouse); $lots = []; foreach (InventoryLotAllocation::query()->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->selectRaw('lot_id, SUM(quantity_milli) AS quantity')->groupBy('lot_id')->orderBy('lot_id')->get() as $allocation) { $identity = SupplyRecord::query()->whereKey($allocation->lot_id)->firstOrFail(); $lot = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $warehouse)->first(); $lastDay = empty($lot->data['expires_on']) ? null : CarbonImmutable::parse($lot->data['expires_on'])->subDays($setup->data['minimum_remaining_days'])->toDateString(); $identified = ! empty($lot->data['code']) && (! empty($lot->data['expires_on']) || (! $setup->data['requires_expiry'] && ($lot->data['no_expiry'] ?? false))); $eligible = $identified && ($lastDay === null || $lastDay >= today()->toDateString()); $released = $state?->state === 'released' && ($state->data['metadata_id'] ?? null) === $lot->id; $quantity = (int) $allocation->getAttribute('quantity'); $lots[] = ['id' => $identity->id, 'record_key' => $lot->record_key, 'version' => $lot->version, 'metadata_id' => $lot->id, 'data' => $lot->data, 'quantity_milli' => $quantity, 'usable_milli' => $eligible && $released ? $quantity : 0, 'eligible' => $eligible, 'state' => ! $identified ? 'unidentified' : (! $eligible ? 'expired' : ($released ? 'released' : 'held')), 'last_usable_on' => $lastDay, 'quality_released' => $released, 'release_mode' => $state?->snapshot['release_mode'] ?? 'independent', 'release_setup_id' => $state?->snapshot['release_setup_id'] ?? null, 'state_version' => $state->version ?? 0, 'contributors' => array_values(array_unique([...$lot->snapshot['contributors'], ...($state?->snapshot['pending_contributors'] ?? [])])), 'origin' => $identity->snapshot['origin'] ?? null]; } $total = array_sum(array_column($lots, 'quantity_milli')); $usable = array_sum(array_column($lots, 'usable_milli')); return ['setup_id' => $setup->id, 'physical' => $physical, 'reconciled' => $total === $physical['quantity'], 'usable_milli' => $usable, 'blocked_milli' => $total - $usable, 'lots' => $lots]; } /** @param array $forecast * @param array{quantity:int,version:int} $balance * @param array $events * @return array */ public function project(SupplyRecord $policy, array $forecast, array $balance, array $events, string $date): array { $context = $this->context($policy->stock_item_id, $policy->warehouse_id); if ($context === null) { return app(ReplenishmentProjection::class)->calculate($forecast, $balance['quantity'], $date, $events['reservations'], $events['receipts'], $events['consumed_today'], $events['fulfilled_needs']); } if (! $context['reconciled']) { return ['available' => false, 'reason' => 'Confira a repartição por lotes: não corresponde ao saldo físico.', 'lot_context' => $context]; } $buckets = array_map(fn ($l) => ['quantity' => $l['usable_milli'], 'last_usable_on' => $l['last_usable_on']], $context['lots']); usort($buckets, fn ($a, $b) => ($a['last_usable_on'] ?? '9999-12-31') <=> ($b['last_usable_on'] ?? '9999-12-31')); $projection = app(ReplenishmentProjection::class)->calculate($forecast, $context['usable_milli'], $date, $events['reservations'], [], $events['consumed_today'], $events['fulfilled_needs'], $buckets); return [...$projection, 'physical_milli' => $context['physical']['quantity'], 'usable_milli' => $context['usable_milli'], 'lot_context' => $context, 'lot_warning' => 'Entradas futuras sem identificação e libertação de lote não são consideradas utilizáveis nesta previsão.']; } /** @return array{usable_milli:int,blocked_milli:int,reconciled:bool}|null */ public function summary(int $item, int $warehouse): ?array { $context = $this->context($item, $warehouse); return $context === null ? null : ['usable_milli' => $context['usable_milli'], 'blocked_milli' => $context['blocked_milli'], 'reconciled' => $context['reconciled']]; } /** @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:prepare,activate,identify,hold,release', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); $permission = match ($v['action']) { 'activate' => 'inventory_lot_review', 'hold' => 'inventory_lot_hold', 'release' => 'inventory_lot_release', default => 'inventory_lot_prepare', }; abort_unless($this->allows($actor, $permission), 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á utilizada com outros dados.'); return $retry; } $old = empty($v['record_key']) ? null : SupplyRecord::query()->where('record_key', $v['record_key'])->latest('version')->firstOrFail(); $this->check((int) $v['version'] === ($old->version ?? 0), 'version', 'O registo mudou. Actualize a página.'); if ($v['action'] === 'prepare') { $this->check($old === null || ($old->kind === 'lot_setup' && in_array($old->state, ['draft', 'active'], true)), 'action', 'Seleccione a configuração deste artigo/local.'); $d = Validator::make($v['data'], ['stock_item_id' => 'required|integer', 'warehouse_id' => 'required|integer', 'requires_expiry' => 'required|boolean', 'minimum_remaining_days' => 'required|integer|min:0|max:3650', 'quarantine_on_receipt' => 'sometimes|boolean', 'lots' => 'present|array|max:100'])->validate(); StockItem::query()->findOrFail($d['stock_item_id']); Warehouse::query()->findOrFail($d['warehouse_id']); $active = $this->setup((int) $d['stock_item_id'], (int) $d['warehouse_id']); $this->check($active === null || ($old !== null && $old->record_key === $active->record_key), 'action', 'Actualize por referência à configuração activa; não crie um segundo controlo.'); $this->check($old?->state !== 'active' || ($old->stock_item_id === (int) $d['stock_item_id'] && $old->warehouse_id === (int) $d['warehouse_id']), 'data', 'Uma revisão conserva o artigo/local da origem.'); $this->check($old?->state !== 'draft' || empty($old->snapshot['previous_setup_id']) || ($active !== null && $active->id === $old->snapshot['previous_setup_id']), 'action', 'A configuração de origem mudou. Prepare nova revisão.'); $balance = app(StockWorkflow::class)->balance((int) $d['stock_item_id'], (int) $d['warehouse_id']); $d['stock_item_id'] = (int) $d['stock_item_id']; $d['warehouse_id'] = (int) $d['warehouse_id']; $d['requires_expiry'] = (bool) $d['requires_expiry']; $d['minimum_remaining_days'] = (int) $d['minimum_remaining_days']; $d['quarantine_on_receipt'] = (bool) ($d['quarantine_on_receipt'] ?? true); if ($active !== null) { $this->check($d['lots'] === [], 'data.lots', 'Uma revisão altera parâmetros; não volta a repartir ou criar saldo inicial.'); } else { $d['lots'] = $this->split($d['lots'], $balance['quantity']); } $context = $active === null ? null : $this->context($active->stock_item_id, $active->warehouse_id); $this->check($context === null || $context['reconciled'], 'action', 'Reconcilie o saldo antes de alterar parâmetros.'); $snapshot = ['balance' => $balance, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])), 'previous_setup_id' => $active?->id, 'lot_stamp' => $context === null ? null : hash('sha256', json_encode($context, JSON_THROW_ON_ERROR)), 'impact' => $context === null ? null : $this->impact($context, $d), 'item' => StockItem::query()->findOrFail($d['stock_item_id'])->only(['id', 'code', 'name', 'unit']), 'warehouse' => Warehouse::query()->findOrFail($d['warehouse_id'])->only(['id', 'code', 'name'])]; return $this->append($actor, 'lot_setup', 'draft', $d, $snapshot, $v['reason'], $old, $v['operation_key'], $hash); } if ($v['action'] === 'activate') { $this->check($old?->kind === 'lot_setup' && $old->state === 'draft', 'action', 'Prepare primeiro a conferência do saldo.'); $this->check(! in_array($actor->id, $old->snapshot['contributors'], true), 'action', 'A activação exige conferência independente da preparação.'); $active = $this->setup($old->stock_item_id, $old->warehouse_id); $this->check(($active?->id) === ($old->snapshot['previous_setup_id'] ?? null), 'action', 'A configuração activa mudou desde a preparação. Actualize a revisão.'); $this->check(app(StockWorkflow::class)->balance($old->stock_item_id, $old->warehouse_id) === $old->snapshot['balance'], 'action', 'O saldo mudou. Actualize a preparação e confira novamente.'); if ($active !== null) { $context = $this->context($old->stock_item_id, $old->warehouse_id); $this->check(hash('sha256', json_encode($context, JSON_THROW_ON_ERROR)) === $old->snapshot['lot_stamp'], 'action', 'A identificação, qualidade ou disponibilidade mudou. Actualize a previsão de impacto.'); } $active = $this->append($actor, 'lot_setup', 'active', $old->data, $old->snapshot, $v['reason'], $old, $v['operation_key'], $hash); foreach ($old->data['lots'] as $entry) { $lot = $this->append($actor, 'lot', 'registered', [...$entry, 'stock_item_id' => $old->stock_item_id, 'warehouse_id' => $old->warehouse_id], ['contributors' => $old->snapshot['contributors'], 'origin' => ['setup_id' => $active->id]], 'Identificação inicial do saldo conferido.'); $this->allocate($lot->id, $old->warehouse_id, $entry['quantity_milli'], null, $active); } return $active; } if ($v['action'] === 'identify') { $this->check($old?->kind === 'lot', 'action', 'Seleccione a identificação existente.'); $metadata = $this->metadata($v['data']); // A correction invalidates the previous quality release by metadata version. return $this->append($actor, 'lot', 'registered', [...$old->data, ...$metadata], [...$old->snapshot, 'contributors' => array_values(array_unique([...$old->snapshot['contributors'], $actor->id]))], $v['reason'], $old, $v['operation_key'], $hash); } $d = Validator::make($v['data'], ['lot_id' => 'required|integer', 'warehouse_id' => 'required|integer', 'state_version' => 'required|integer|min:0'])->validate(); $identity = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->whereKey($d['lot_id'])->firstOrFail(); $context = $this->context($identity->stock_item_id, (int) $d['warehouse_id']); $entry = collect($context['lots'] ?? [])->firstWhere('id', $identity->id); $this->check($entry !== null && $context['reconciled'] && $entry['quantity_milli'] > 0, 'action', 'Lote sem saldo conferido neste armazém.'); $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $d['warehouse_id'])->first(); $this->check((int) $d['state_version'] === ($state->version ?? 0) && $old?->id === $entry['metadata_id'], 'version', 'A identificação ou a decisão mudou. Actualize a página.'); if ($v['action'] === 'release') { $this->check(! in_array($actor->id, $entry['contributors'], true), 'action', 'A libertação exige pessoa independente da identificação e recepção.'); $this->check($entry['eligible'], 'action', 'Confirme identificação e validade suficiente antes de libertar.'); } return $this->append($actor, 'lot_state', $v['action'] === 'release' ? 'released' : 'held', ['lot_id' => $identity->id, 'metadata_id' => $old->id, 'stock_item_id' => $identity->stock_item_id, 'warehouse_id' => (int) $d['warehouse_id']], ['pending_contributors' => $v['action'] === 'release' ? [] : ($state?->snapshot['pending_contributors'] ?? [])], $v['reason'], $state, $v['operation_key'], $hash); }); } /** @param array $input * @return array{code:?string,expires_on:?string,no_expiry:bool} */ private function metadata(array $input): array { $d = Validator::make($input, ['code' => 'nullable|string|max:100', 'expires_on' => 'nullable|date_format:Y-m-d', 'no_expiry' => 'sometimes|boolean'])->validate(); $this->check(empty($d['expires_on']) || ! ($d['no_expiry'] ?? false), 'data', 'Escolha validade ou confirmação de artigo sem prazo.'); return ['code' => empty(trim($d['code'] ?? '')) ? null : trim($d['code']), 'expires_on' => $d['expires_on'] ?? null, 'no_expiry' => (bool) ($d['no_expiry'] ?? false)]; } /** @param array $context * @param array $data * @return array{before_milli:int,after_milli:int} */ private function impact(array $context, array $data): array { $after = 0; foreach ($context['lots'] as $lot) { $identified = ! empty($lot['data']['code']) && (! empty($lot['data']['expires_on']) || (! $data['requires_expiry'] && $lot['data']['no_expiry'])); $last = empty($lot['data']['expires_on']) ? null : CarbonImmutable::parse($lot['data']['expires_on'])->subDays($data['minimum_remaining_days'])->toDateString(); if ($lot['quality_released'] && $identified && ($last === null || $last >= today()->toDateString())) { $after += $lot['quantity_milli']; } } return ['before_milli' => $context['usable_milli'], 'after_milli' => $after]; } /** @param list> $entries * @return list> */ public function split(array $entries, int $quantity): array { if ($quantity === 0) { $this->check($entries === [], 'data.lots', 'Saldo nulo não recebe lotes iniciais.'); return []; } if ($entries === []) { $entries = [[]]; // Unknown identity is quarantined, never treated as unlimited validity. } $result = []; foreach ($entries as $entry) { $v = Validator::make($entry, ['quantity' => ['nullable', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/']])->validate(); $q = count($entries) === 1 && empty($v['quantity']) ? $quantity : ReceiptWorkflow::milli($v['quantity'] ?? '0'); $this->check($q > 0, 'data.lots', 'Indique a quantidade positiva de cada lote quando existe repartição.'); $result[] = [...$this->metadata($entry), 'quantity_milli' => $q]; } $this->check(array_sum(array_column($result, 'quantity_milli')) === $quantity, 'data.lots', 'A soma dos lotes deve corresponder exactamente à quantidade física.'); return $result; } /** @param list> $entries */ public function receive(User $actor, StockMovement $movement, array $entries, string $receivedOn): void { $setup = $this->setup($movement->stock_item_id, $movement->warehouse_id); if ($setup === null) { $this->check($entries === [], 'items', 'Active e confira o controlo por lotes antes de registar a repartição.'); return; } foreach ($this->split($entries, $movement->quantity_milli) as $entry) { $this->check(empty($entry['expires_on']) || $entry['expires_on'] >= $receivedOn, 'items', 'Um lote já vencido na data da recepção não pode ser aceite.'); $lot = $this->append($actor, 'lot', 'registered', [...$entry, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], ['contributors' => [$actor->id], 'origin' => ['movement_id' => $movement->id, 'receipt_item_id' => $movement->goods_receipt_item_id, 'receipt_setup_id' => $setup->id]], 'Identificação da recepção sob a configuração aprovada; disponibilidade determinada pela regra e qualidade.'); $this->allocate($lot->id, $movement->warehouse_id, $entry['quantity_milli'], $movement); $quality = GoodsReceiptItem::query()->whereKey($movement->goods_receipt_item_id)->firstOrFail(); $identified = ! empty($entry['code']) && (! empty($entry['expires_on']) || (! $setup->data['requires_expiry'] && $entry['no_expiry'])); $valid = empty($entry['expires_on']) || CarbonImmutable::parse($entry['expires_on'])->subDays($setup->data['minimum_remaining_days'])->toDateString() >= today()->toDateString(); if (! ($setup->data['quarantine_on_receipt'] ?? true) && $quality->quality_status === 'compliant' && $identified && $valid) { $this->append($actor, 'lot_state', 'released', ['lot_id' => $lot->id, 'metadata_id' => $lot->id, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], ['release_mode' => 'receipt_policy', 'release_setup_id' => $setup->id, 'pending_contributors' => []], 'Dispensa de quarentena de recepção conforme, por regra previamente aprovada.'); } } $this->check($this->context($movement->stock_item_id, $movement->warehouse_id)['reconciled'], 'items', 'Existe divergência na repartição anterior do stock. Confira as origens antes de receber.'); } /** @return list */ public function issuePlan(int $item, int $warehouse, int $quantity): array { $context = $this->context($item, $warehouse); if ($context === null) { return []; } $this->check($context['reconciled'], 'quantity', 'Repartição por lotes divergente do saldo físico. Suspenda operações e confira as origens.'); $this->check($quantity <= $context['usable_milli'], 'quantity', 'Quantidade utilizável insuficiente: lotes em quarentena, sem identificação ou sem validade suficiente não podem sair.'); $lots = $context['lots']; usort($lots, fn ($a, $b) => [$a['last_usable_on'] ?? '9999-12-31', $a['id']] <=> [$b['last_usable_on'] ?? '9999-12-31', $b['id']]); $plan = []; foreach ($lots as $lot) { $take = min($quantity, $lot['usable_milli']); if ($take > 0) { $plan[] = ['lot_id' => $lot['id'], 'quantity' => $take]; $quantity -= $take; } } return $plan; } public function allocate(int $lotId, int $warehouse, int $quantity, ?StockMovement $movement = null, ?SupplyRecord $setup = null): void { $lot = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->findOrFail($lotId); InventoryLotAllocation::query()->create(['tenant_id' => $lot->tenant_id, 'stock_item_id' => $lot->stock_item_id, 'warehouse_id' => $warehouse, 'lot_id' => $lot->id, 'stock_movement_id' => $movement?->id, 'setup_record_id' => $setup?->id, 'quantity_milli' => $quantity, 'created_at' => now()]); } /** @return list */ public function transferPlan(int $item, int $source, int $destination, int $quantity): array { $from = $this->setup($item, $source); $to = $this->setup($item, $destination); if ($from === null && $to === null) { return []; } $this->check($quantity > 0, 'quantity', 'A transferência exige quantidade positiva.'); $this->check($from !== null && $to !== null, 'destination_warehouse_id', 'Confira e active o controlo por lotes deste artigo nos dois armazéns antes de transferir.'); $context = $this->context($item, $destination); $this->check($context !== null && $context['reconciled'], 'destination_warehouse_id', 'A repartição dos lotes no destino deve corresponder ao saldo físico.'); return $this->issuePlan($item, $source, $quantity); } /** Reuses identities; destination quality is independent of custody. * @param list $plan */ public function transferred(User $actor, StockMovement $arrival, array $plan, int $responsible): void { foreach ($plan as $allocation) { $identity = SupplyRecord::query()->whereKey($allocation['lot_id'])->firstOrFail(); $metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $arrival->warehouse_id)->first(); $this->allocate($identity->id, $arrival->warehouse_id, $allocation['quantity'], $arrival); $this->append($actor, 'lot_state', 'held', ['lot_id' => $identity->id, 'metadata_id' => $metadata->id, 'stock_item_id' => $arrival->stock_item_id, 'warehouse_id' => $arrival->warehouse_id], ['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), $actor->id, $responsible])), 'transfer' => ['movement_id' => $arrival->id, 'operation_id' => $arrival->stock_operation_id, 'quantity_milli' => $allocation['quantity'], 'previous_state_id' => $state?->id]], 'Entrada por transferência; o lote neste local aguarda nova conferência independente.', $state); } $context = $this->context($arrival->stock_item_id, $arrival->warehouse_id); $this->check($context !== null && $context['reconciled'], 'destination_warehouse_id', 'A transferência não reconciliou os lotes no destino.'); } /** Freeze only the selected lots, without exposing unrelated quality history. * @param list $plan * @return list> */ public function partitionSnapshot(array $plan, int $warehouse): array { $rows = []; foreach ($plan as $entry) { $identity = SupplyRecord::query()->whereKey($entry['lot_id'])->firstOrFail(); $metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $warehouse)->first(); $rows[] = [...$entry, 'metadata_id' => $metadata->id, 'source_quality_id' => $state?->id, 'code' => $metadata->data['code'], 'expires_on' => $metadata->data['expires_on'], 'no_expiry' => $metadata->data['no_expiry']]; } return $rows; } /** @param array $row */ public function found(User $actor, StockOperation $count, array $row): SupplyRecord { return $this->append($actor, 'lot', 'registered', ['stock_item_id' => $count->stock_item_id, 'warehouse_id' => $count->warehouse_id, 'code' => $row['code'], 'expires_on' => $row['expires_on'], 'no_expiry' => $row['no_expiry'], 'quantity_milli' => $row['counted_milli']], ['contributors' => array_values(array_unique([$count->created_by, $count->responsible_id])), 'origin' => ['count_id' => $count->id, 'found_key' => $row['new_key']]], 'Lote encontrado na contagem; origem física preservada, sem inventar recepção de fornecedor.'); } /** @param list> $rows */ public function adjusted(User $actor, StockOperation $count, StockMovement $movement, array $rows, int $responsible): void { foreach ($rows as $row) { if ($row['delta_milli'] === 0 || ($row['delta_milli'] > 0) !== ($movement->quantity_milli > 0)) { continue; } $this->allocate($row['lot_id'], $movement->warehouse_id, $row['delta_milli'], $movement); if ($row['delta_milli'] > 0) { $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $row['lot_id'])->where('warehouse_id', $movement->warehouse_id)->first(); $this->append($actor, 'lot_state', 'held', ['lot_id' => $row['lot_id'], 'metadata_id' => $row['metadata_id'], 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], ['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), $actor->id, $responsible, $count->created_by, $count->responsible_id])), 'adjustment' => ['movement_id' => $movement->id, 'count_id' => $count->id, 'quantity_milli' => $row['delta_milli']]], 'Quantidade encontrada em contagem; o lote neste local aguarda conferência independente de qualidade.', $state); } } } /** @param list> $parts * @return list */ public function reverseMovement(User $actor, SupplyRecord $reversal, StockMovement $movement, array $parts, int $sourceMovement): array { $links = []; $contributors = array_values(array_unique([...$reversal->snapshot['contributors'], $reversal->snapshot['reviewed_by'], $actor->id])); foreach ($parts as $part) { if ($part['lot_id'] === null) { $identity = $this->append($actor, 'lot', 'registered', ['stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id, 'code' => null, 'expires_on' => null, 'no_expiry' => false, 'quantity_milli' => $part['quantity']], ['contributors' => $contributors, 'origin' => ['reversal_key' => $reversal->record_key, 'source_movement_id' => $sourceMovement]], 'Reposição de uma saída anterior ao controlo por lotes; identidade desconhecida, sem recepção de fornecedor presumida.'); } else { $identity = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->whereKey($part['lot_id'])->firstOrFail(); } $this->allocate($identity->id, $movement->warehouse_id, $part['quantity'], $movement); if ($part['quantity'] > 0) { $metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail(); $state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $movement->warehouse_id)->first(); $this->append($actor, 'lot_state', 'held', ['lot_id' => $identity->id, 'metadata_id' => $metadata->id, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id], ['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), ...$contributors])), 'reversal' => ['record_key' => $reversal->record_key, 'movement_id' => $movement->id, 'source_movement_id' => $sourceMovement]], 'Quantidade reposta por estorno; o lote no local exige nova conferência independente de qualidade.', $state); } $links[] = ['movement_id' => $movement->id, 'lot_id' => $identity->id, 'quantity' => $part['quantity']]; } return $links; } /** @param array $data * @param array $snapshot */ private function append(User $actor, string $kind, string $state, array $data, array $snapshot, string $reason, ?SupplyRecord $old = null, ?string $operation = null, ?string $hash = null): SupplyRecord { $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'], 'data' => $data, 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $reason, 'created_by' => $actor->id, 'operation_key' => $operation ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)), 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'inventory.'.$kind.'.'.$state, null, $old?->toArray(), $record->toArray()); app(AutomaticPurchases::class)->signal($actor, $data['stock_item_id'], $data['warehouse_id']); return $record; } private function check(bool $condition, string $field, string $message): void { if (! $condition) { throw ValidationException::withMessages([$field => $message]); } } }