>,setup_id:int}|null */ public function source(int $item, int $warehouse): ?array { $context = app(InventoryLots::class)->context($item, $warehouse); if ($context === null) { return null; } $this->check($context['reconciled'], 'lot_counts', 'Confira as origens: a repartição não corresponde ao saldo físico.'); $lots = array_map(fn ($lot) => ['lot_id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'quantity_milli' => $lot['quantity_milli'], 'code' => $lot['data']['code'], 'expires_on' => $lot['data']['expires_on'], 'no_expiry' => $lot['data']['no_expiry']], $context['lots']); $basis = ['setup_id' => $context['setup_id'], 'lots' => $lots]; return [...$basis, 'hash' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))]; } /** @param array $data * @return array|null */ public function prepare(int $item, int $warehouse, int $total, array $data): ?array { $source = $this->source($item, $warehouse); if ($source === null) { $this->check(empty($data['lot_counts']), 'lot_counts', 'Confira e active o controlo antes de contar por lote.'); return null; } $this->check(($data['lot_version'] ?? '') === $source['hash'], 'lot_version', 'Os lotes ou a identificação mudaram. Actualize e volte a conferir a contagem.'); $input = Validator::make($data, ['lot_counts' => 'present|array|max:500', 'lot_counts.*.lot_id' => 'nullable|integer|min:1', 'lot_counts.*.new_key' => 'nullable|uuid', 'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean'])->validate(); $rows = []; $seen = []; $known = collect($source['lots'])->keyBy('lot_id'); foreach ($input['lot_counts'] as $entry) { $quantity = ReceiptWorkflow::milli($entry['quantity']); if (! empty($entry['lot_id'])) { $id = (int) $entry['lot_id']; $this->check($known->has($id) && ! isset($seen['lot-'.$id]) && empty($entry['new_key']), 'lot_counts', 'Seleccione uma única vez cada lote deste artigo/local.'); $seen['lot-'.$id] = true; $old = $known->get($id); $rows[] = ['lot_id' => $id, 'metadata_id' => $old['metadata_id'], 'code' => $old['code'], 'expires_on' => $old['expires_on'], 'no_expiry' => $old['no_expiry'], 'previous_milli' => $old['quantity_milli'], 'counted_milli' => $quantity, 'delta_milli' => $quantity - $old['quantity_milli']]; } else { $key = $entry['new_key'] ?? ''; $this->check($key !== '' && ! isset($seen['new-'.$key]) && $quantity > 0, 'lot_counts', 'Identifique cada novo lote encontrado com quantidade positiva, sem duplicar a linha.'); $this->check(empty($entry['expires_on']) || ! ($entry['no_expiry'] ?? false), 'lot_counts', 'Escolha prazo de validade ou confirmação de artigo sem prazo.'); $seen['new-'.$key] = true; $rows[] = ['lot_id' => null, 'new_key' => $key, 'metadata_id' => null, 'code' => empty(trim($entry['code'] ?? '')) ? null : trim($entry['code']), 'expires_on' => $entry['expires_on'] ?? null, 'no_expiry' => (bool) ($entry['no_expiry'] ?? false), 'previous_milli' => 0, 'counted_milli' => $quantity, 'delta_milli' => $quantity]; } } foreach ($source['lots'] as $lot) { $this->check($lot['quantity_milli'] === 0 || isset($seen['lot-'.$lot['lot_id']]), 'lot_counts', 'Indique a contagem de todos os lotes com saldo, incluindo zero quando não encontrou quantidade.'); } $this->check(array_sum(array_column($rows, 'counted_milli')) === $total, 'quantity', 'O total deve corresponder exactamente às quantidades contadas por lote.'); return ['source_hash' => $source['hash'], 'setup_id' => $source['setup_id'], 'rows' => $rows, 'increase_milli' => array_sum(array_map(fn ($r) => max(0, $r['delta_milli']), $rows)), 'decrease_milli' => array_sum(array_map(fn ($r) => max(0, -$r['delta_milli']), $rows))]; } /** @return array */ public function adjustment(User $actor, StockOperation $count): array { abort_unless(app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 403); $this->check(! in_array($actor->id, [$count->created_by, $count->responsible_id], true), 'count_id', 'A conferência do ajuste exige pessoa diferente de quem registou e assumiu a contagem.'); $plan = $count->snapshot['lot_count'] ?? null; $this->check($plan !== null, 'count_id', 'Esta contagem não preservou repartição por lote. Faça nova contagem.'); $source = $this->source($count->stock_item_id, $count->warehouse_id); $this->check($source !== null && $plan['source_hash'] === $source['hash'], 'count_id', 'A repartição ou identificação mudou desde a contagem. Faça nova conferência física.'); $this->check($plan['increase_milli'] + $plan['decrease_milli'] > 0, 'count_id', 'Contagem sem divergências de quantidade ou repartição.'); foreach ($plan['rows'] as &$row) { if ($row['lot_id'] === null) { $lot = app(InventoryLots::class)->found($actor, $count, $row); $row['lot_id'] = $lot->id; $row['metadata_id'] = $lot->id; } } unset($row); return $plan; } private function check(bool $condition, string $field, string $message): void { if (! $condition) { throw ValidationException::withMessages([$field => $message]); } } }