Files
compras/app/Services/InventoryCounts.php
2026-11-02 08:08:05 +02:00

102 lines
6.1 KiB
PHP

<?php
namespace App\Services;
use App\Models\StockOperation;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
/** Physical observations reuse lot identities and never themselves alter stock. */
class InventoryCounts
{
/** @return array{hash:string,lots:list<array<string,mixed>>,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<string,mixed> $data
* @return array<string,mixed>|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<string,mixed> */
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]);
}
}
}