203 lines
13 KiB
PHP
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]);
|
|
}
|
|
}
|
|
}
|