Files
compras/app/Services/WarehouseFulfilment.php
2026-11-02 20:27:11 +02:00

203 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Models\Equipment;
use App\Models\StockItem;
use App\Models\User;
use App\Models\WarehouseRequest;
use App\Models\WarehouseRequestEvent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class WarehouseFulfilment
{
/** @return array<string,mixed> */
public function progress(WarehouseRequest $request): array
{
$events = Schema::hasTable('warehouse_request_events') ? WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->orderBy('version')->get() : collect();
$state = 'draft';
$reviewer = null;
$partial = false;
$partialRequested = false;
$delivered = array_fill(0, count($request->snapshot['lines']), 0);
$receipts = [];
foreach ($events as $event) {
if ($event->action === 'submit') {
$state = 'submitted';
$partialRequested = $event->data['partial_allowed'] ?? false;
}
if ($event->action === 'reject') {
$state = 'rejected';
}
if ($event->action === 'cancel') {
$state = 'cancelled';
}
if ($event->action === 'authorize') {
$state = 'authorized';
$reviewer = $event->actor_id;
$partial = $event->data['partial_allowed'];
}
if ($event->action === 'deliver') {
foreach ($event->data['lines'] as $line) {
$delivered[$line['index']] += $line['quantity_milli'];
} $state = 'partial';
}
if ($event->action === 'receive') {
$receipts[] = $event->data['delivery_event_id'];
}
}
$remaining = [];
foreach ($request->snapshot['lines'] as $index => $line) {
$remaining[$index] = $line['quantity_milli'] - $delivered[$index];
}
$cancelled = $state === 'cancelled' ? $remaining : array_fill(0, count($remaining), 0);
if ($state === 'cancelled') {
$remaining = array_fill(0, count($remaining), 0);
}
if ($state === 'partial' && array_sum($remaining) === 0) {
$state = 'delivered';
}
if ($state === 'delivered' && count($receipts) === $events->where('action', 'deliver')->count()) {
$state = 'closed';
}
return ['state' => $state, 'version' => $events->max('version') ?? 0, 'reviewer_id' => $reviewer, 'partial_allowed' => $partial, 'partial_requested' => $partialRequested,
'delivered' => $delivered, 'remaining' => $remaining, 'cancelled' => $cancelled, 'reserved' => app(WarehouseReservations::class)->lines($request), 'reservation_ready' => app(WarehouseReservations::class)->ready(), 'received_deliveries' => $receipts, 'events' => $events->map(fn ($event) => $event->only(['id', 'version', 'action', 'actor_id', 'data', 'reason', 'created_at']))->all()];
}
/** @param array<string,mixed> $input */
public function command(User $actor, int $id, array $input): WarehouseRequestEvent
{
return DB::transaction(function () use ($actor, $id, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Instalação da autorização/entrega pendente.');
$r = WarehouseRequest::query()->lockForUpdate()->findOrFail($id);
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:submit,authorize,reject,deliver,receive,reserve,cancel', 'reason' => 'required|string|min:10|max:4000',
'partial_allowed' => 'sometimes|boolean', 'lines' => 'sometimes|array|min:1|max:50', 'lines.*.index' => 'required|integer|distinct|min:0', 'lines.*.quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', 'delivery_event_id' => 'sometimes|integer|min:1'])->validate();
$action = $data['action'];
$owner = $r->created_by === $actor->id;
if (in_array($action, ['submit', 'receive'], true)) {
abort_unless($owner && $actor->hasFunctions(['warehouse_request_prepare']), 403);
}
if (in_array($action, ['authorize', 'reject', 'reserve', 'cancel'], true)) {
abort_unless(! $owner && $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id === $r->snapshot['requester']['department_id'], 403);
}
if ($action === 'deliver') {
abort_unless(! $owner && $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']), 403);
}
$hash = hash('sha256', json_encode([$id, $data], JSON_THROW_ON_ERROR));
$old = WarehouseRequestEvent::query()->where('actor_id', $actor->id)->where('operation_key', $data['operation_key'])->first();
if ($old !== null) {
$this->check($old->input_hash === $hash, 'operation_key', 'Chave utilizada com outro conteúdo.');
return $old;
}
$p = $this->progress($r);
$this->check((int) $data['version'] === $p['version'], 'version', 'O pedido mudou. Actualize antes de continuar.');
$eventData = [];
if (in_array($action, ['reserve', 'cancel'], true)) {
$this->check(in_array($p['state'], ['authorized', 'partial'], true), 'action', 'A reserva/libertação exige um pedido autorizado com saldo pendente.');
}
if ($action === 'submit') {
$this->check($p['state'] === 'draft', 'action', 'Só pode submeter um rascunho.');
$eventData = ['partial_allowed' => $data['partial_allowed'] ?? false];
}
if (in_array($action, ['authorize', 'reject'], true)) {
$this->check($p['state'] === 'submitted', 'action', 'O pedido não aguarda decisão.');
}
if ($action === 'authorize') {
$partial = $data['partial_allowed'] ?? false;
$this->check(! $partial || $p['partial_requested'], 'partial_allowed', 'O requisitante não aceitou entregas parciais na submissão.');
$eventData = ['partial_allowed' => $partial, 'availability' => []];
$grouped = [];
foreach ($r->snapshot['lines'] as $line) {
$grouped[$line['item']['id']] = ($grouped[$line['item']['id']] ?? 0) + $line['quantity_milli'];
}
foreach ($grouped as $itemId => $quantity) {
$balance = app(StockWorkflow::class)->balance($itemId, $r->warehouse_id);
$lots = app(InventoryLots::class)->context($itemId, $r->warehouse_id);
$usable = $lots === null ? $balance['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null);
$this->check($usable !== null && ($partial || $usable >= $quantity), 'availability', 'Stock utilizável insuficiente para autorização de entrega integral.');
$eventData['availability'][] = ['stock_item_id' => $itemId, 'usable_milli' => $usable, 'requested_milli' => $quantity, 'ledger_version' => $balance['version']];
}
}
if (in_array($action, ['submit', 'authorize', 'deliver', 'reserve'], true)) {
foreach ($r->snapshot['lines'] as $line) {
$item = StockItem::query()->whereKey($line['item']['id'])->firstOrFail();
$this->check($item->only(['id', 'code', 'name', 'unit']) === $line['item'], 'source', 'O cadastro do artigo mudou; é necessário um novo rascunho conferido.');
if ($line['equipment'] !== null) {
$equipment = Equipment::query()->whereKey($line['equipment']['id'])->firstOrFail();
$this->check($equipment->only(['id', 'code', 'description']) === $line['equipment'] && $equipment->department_id === $r->snapshot['requester']['department_id'], 'source', 'O equipamento de destino mudou. Reveja a origem.');
}
}
}
if ($action === 'deliver') {
$this->check(in_array($p['state'], ['authorized', 'partial'], true), 'action', 'A entrega exige uma requisição autorizada com saldo pendente.');
abort_if($actor->id === $p['reviewer_id'], 403, 'Quem autorizou não pode entregar.');
$this->check(! empty($data['lines']), 'lines', 'Indique as quantidades efectivamente entregues.');
$quantities = array_fill(0, count($p['remaining']), 0);
foreach ($data['lines'] as $line) {
$index = (int) $line['index'];
$quantity = ReceiptWorkflow::milli($line['quantity']);
$this->check(isset($p['remaining'][$index]) && $quantity > 0 && $quantity <= $p['remaining'][$index], 'lines', 'Quantidade fora do saldo autorizado.');
$quantities[$index] = $quantity;
}
if (! $p['partial_allowed']) {
$this->check($quantities === $p['remaining'], 'lines', 'A decisão exige entrega integral.');
}
$eventData['lines'] = [];
foreach ($quantities as $index => $quantity) {
if ($quantity === 0) {
continue;
}
$line = $r->snapshot['lines'][$index];
app(WarehouseReservations::class)->consume($actor, $r, $index, $quantity, $data['operation_key']);
$operation = app(StockWorkflow::class)->record($actor, ['kind' => 'issue', 'reference' => 'RA-'.$r->id.'-V'.($p['version'] + 1).'-L'.$index,
'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $r->created_by, 'warehouse_id' => $r->warehouse_id, 'stock_item_id' => $line['item']['id'],
'quantity' => intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT), 'destination' => $r->snapshot['destination'], 'reason' => 'Saída pela requisição RA-'.$r->id.': '.$data['reason'],
...($line['equipment'] === null ? [] : ['equipment_id' => $line['equipment']['id']])]);
$eventData['lines'][] = ['index' => $index, 'quantity_milli' => $quantity, 'stock_operation_id' => $operation->id];
}
$eventData['recipient'] = $r->snapshot['requester'];
}
if ($action === 'receive') {
$delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'deliver')->whereKey($data['delivery_event_id'] ?? 0)->firstOrFail();
$this->check(! in_array($delivery->id, $p['received_deliveries'], true), 'delivery_event_id', 'Recepção já confirmada.');
$eventData = ['delivery_event_id' => $delivery->id];
}
$eventData['actor'] = $actor->only(['id', 'name']);
$event = WarehouseRequestEvent::query()->create(['tenant_id' => $actor->tenant_id, 'warehouse_request_id' => $id, 'actor_id' => $actor->id, 'version' => $p['version'] + 1,
'operation_key' => $data['operation_key'], 'input_hash' => $hash, 'action' => $action, 'reason' => $data['reason'], 'data' => $eventData, 'created_at' => now()]);
if ($action === 'authorize') {
app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $data['partial_allowed'] ?? false, $data['operation_key']);
}
if ($action === 'reserve') {
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'authorize')->firstOrFail();
app(WarehouseReservations::class)->reserve($actor, $r, $authorization, $p['remaining'], $p['partial_allowed'], $data['operation_key']);
}
if ($action === 'cancel') {
app(WarehouseReservations::class)->release($actor, $r, $data['operation_key']);
}
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_request.'.$action, null, null, ['warehouse_request_id' => $id, 'event_id' => $event->id, 'data' => $eventData, 'reason' => $data['reason']]);
foreach ($r->snapshot['lines'] as $line) {
app(AutomaticPurchases::class)->signal($actor, $line['item']['id'], $r->warehouse_id);
}
return $event;
});
}
private function check(bool $valid, string $field, string $message): void
{
if (! $valid) {
throw ValidationException::withMessages([$field => $message]);
}
}
}