*/ 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 $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'], $eventData['partial_allowed'], $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]); } } }