Files
compras/app/Services/ReceiptWorkflow.php
T

199 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\User;
use App\Models\Warehouse;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ReceiptWorkflow
{
public function __construct(private ConflictGuard $conflicts, private RequisitionWorkflow $audit) {}
public static function milli(string $quantity): int
{
return BigDecimal::of($quantity)->multipliedBy(1000)->toScale(0)->toInt();
}
private function recordedQuantity(int $lineId, string $column): int
{
$sum = GoodsReceiptItem::query()->where('purchase_order_item_id', $lineId)->sum($column);
if (! is_int($sum) && ! is_string($sum)) {
throw new \LogicException('A soma das quantidades deve ser um inteiro exacto.');
}
return BigDecimal::of($sum)->toInt();
}
/** @return array{lines: list<array<string, mixed>>, state: string, has_divergences: bool} */
public function progress(PurchaseOrder $order): array
{
$lines = [];
$complete = true;
$delivered = true;
$started = false;
$divergences = false;
foreach ($order->items()->orderBy('id')->get() as $item) {
$ordered = self::milli($item->quantity);
$received = $this->recordedQuantity($item->id, 'received_milli');
$accepted = $this->recordedQuantity($item->id, 'accepted_milli');
$lines[] = [
...$item->only(['id', 'description', 'unit', 'purchase_type']),
'ordered_milli' => $ordered, 'received_milli' => $received, 'accepted_milli' => $accepted,
'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $accepted,
];
$complete = $complete && $accepted === $ordered;
$delivered = $delivered && $received === $ordered;
$started = $started || $received > 0;
$divergences = $divergences || GoodsReceiptItem::query()->where('purchase_order_item_id', $item->id)->where('quality_status', '!=', 'compliant')->exists();
}
return ['lines' => $lines, 'state' => ! $started ? 'not_received' : ($complete ? 'complete' : ($delivered ? 'quality_pending' : 'partial')), 'has_divergences' => $divergences];
}
/** @param array<string, mixed> $data */
public function confirm(User $user, PurchaseOrder $order, array $data): GoodsReceipt
{
return DB::transaction(function () use ($user, $order, $data) {
$this->conflicts->lockCompany($user);
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
Gate::authorize('create', [GoodsReceipt::class, $order]);
$deliveryNote = Str::squish(Str::upper($data['delivery_note']));
if ($deliveryNote === '') {
throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']);
}
$inputs = [];
/** @var list<array{purchase_order_item_id: int, received_quantity: string, accepted_quantity: string, quality_status: string, stock_item_id?: int|null, notes?: string|null}> $requested */
$requested = $data['items'];
foreach ($requested as $line) {
$inputs[] = [
'purchase_order_item_id' => $line['purchase_order_item_id'],
'received_milli' => self::milli($line['received_quantity']), 'accepted_milli' => self::milli($line['accepted_quantity']),
'quality_status' => $line['quality_status'], 'stock_item_id' => $line['stock_item_id'] ?? null,
'notes' => isset($line['notes']) ? trim($line['notes']) : null,
];
}
usort($inputs, fn ($a, $b) => $a['purchase_order_item_id'] <=> $b['purchase_order_item_id']);
$canonical = ['delivery_note' => $deliveryNote, 'received_on' => $data['received_on'], 'warehouse_id' => $data['warehouse_id'] ?? null, 'items' => $inputs];
// Normalize form-encoded identifiers to the same representation used by JSON requests.
$canonical['warehouse_id'] = $canonical['warehouse_id'] === null ? null : (string) $canonical['warehouse_id'];
foreach ($canonical['items'] as &$line) {
$line['purchase_order_item_id'] = (string) $line['purchase_order_item_id'];
$line['stock_item_id'] = $line['stock_item_id'] === null ? null : (string) $line['stock_item_id'];
}
unset($line);
$hash = hash('sha256', json_encode($canonical, JSON_THROW_ON_ERROR));
$existing = GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('idempotency_key', $data['idempotency_key'])->first();
if ($existing) {
if ($existing->payload_hash !== $hash) {
throw ValidationException::withMessages(['idempotency_key' => 'Esta chave já confirmou uma recepção com dados diferentes.']);
}
return $existing;
}
if (GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('delivery_note', $deliveryNote)->exists()) {
throw ValidationException::withMessages(['delivery_note' => 'Esta guia já foi confirmada nesta ordem. Consulte o histórico em vez de repetir a recepção.']);
}
if ($order->status !== 'issued' || $data['received_on'] < substr((string) $order->getRawOriginal('issued_at'), 0, 10)) {
throw ValidationException::withMessages(['received_on' => 'A recepção deve referir uma ordem emitida e não pode anteceder a sua emissão.']);
}
$requisition = Requisition::query()->findOrFail($order->requisition_id);
$this->conflicts->assertParticipation($user, $requisition);
$this->conflicts->assertProcess($requisition, $order->supplier_id, $user);
$warehouse = ! empty($data['warehouse_id']) ? Warehouse::query()->whereKey($data['warehouse_id'])->lockForUpdate()->firstOrFail() : null;
$ordered = $order->items()->orderBy('id')->lockForUpdate()->get()->keyBy('id');
$lines = [];
$seen = [];
$acceptedTotal = 0;
foreach ($inputs as $input) {
$source = $ordered->get($input['purchase_order_item_id']);
if (! $source || in_array($source->id, $seen, true)) {
throw ValidationException::withMessages(['items' => 'As linhas devem ser distintas e pertencer à ordem de compra.']);
}
$seen[] = $source->id;
$received = $input['received_milli'];
$accepted = $input['accepted_milli'];
$previous = $this->recordedQuantity($source->id, 'received_milli');
if ($received <= 0 || $accepted < 0 || $accepted > $received || $previous + $received > self::milli($source->quantity)) {
throw ValidationException::withMessages(['items' => 'Quantidade inválida ou superior à quantidade pendente autorizada na ordem.']);
}
if (($input['quality_status'] === 'compliant' && $accepted !== $received) || ($input['quality_status'] === 'nonconforming' && $accepted !== 0)) {
throw ValidationException::withMessages(['items' => 'Conforme exige aceitar a quantidade recebida; Não conforme exige rejeitá-la integralmente. Use Conforme com reservas para aceitação parcial.']);
}
if ($input['quality_status'] !== 'compliant' && empty($input['notes'])) {
throw ValidationException::withMessages(['items' => 'Descreva as reservas, danos ou divergências de qualidade.']);
}
$stockItem = ! empty($input['stock_item_id']) ? StockItem::query()->lockForUpdate()->findOrFail($input['stock_item_id']) : null;
$physical = in_array($source->purchase_type, ['consumable', 'other', 'spare_part']);
if ($source->purchase_type === 'spare_part') {
if (! $source->spare_part_id || ! $source->equipment_id) {
throw ValidationException::withMessages(['items' => 'Peça sem associação autorizada na ordem. A recepção está bloqueada.']);
}
$part = app(EquipmentWorkflow::class)->linkedPart((int) $source->spare_part_id, (int) $source->equipment_id, $requisition->department_id);
if ($accepted > 0 && $stockItem?->id !== $part->stock_item_id) {
throw ValidationException::withMessages(['items' => 'A peça recebida deve usar o artigo de stock autorizado na ordem.']);
}
}
if (! $physical && $source->purchase_type !== 'service') {
throw ValidationException::withMessages(['items' => 'Este tipo de artigo exige controlos adicionais ainda não implementados.']);
}
if ($physical && $accepted > 0 && (! $warehouse || ! $stockItem || Str::lower(trim($stockItem->unit)) !== Str::lower(trim($source->unit)))) {
throw ValidationException::withMessages(['items' => 'Para bens aceites, indique armazém e artigo de stock com a mesma unidade da ordem. Não há conversões automáticas.']);
}
if (! $physical && $stockItem !== null) {
throw ValidationException::withMessages(['items' => 'Serviços não geram existências nem recebem artigo de stock.']);
}
$acceptedTotal += $accepted;
$lines[] = [
'tenant_id' => $user->tenant_id, 'purchase_order_item_id' => $source->id,
'stock_item_id' => $stockItem?->id, 'received_milli' => $received, 'accepted_milli' => $accepted,
'rejected_milli' => $received - $accepted, 'quality_status' => $input['quality_status'], 'notes' => $input['notes'],
'snapshot' => [
'order_item' => $source->only(['id', 'description', 'unit', 'purchase_type', 'quantity']),
'stock_item' => $stockItem?->only(['id', 'code', 'name', 'unit']),
'warehouse' => $warehouse?->only(['id', 'code', 'name']),
'physical' => $physical,
'equipment_id' => $source->equipment_id, 'spare_part_id' => $source->spare_part_id,
],
];
}
$receipt = GoodsReceipt::query()->create([
'tenant_id' => $user->tenant_id, 'purchase_order_id' => $order->id, 'received_by' => $user->id, 'warehouse_id' => $warehouse?->id,
'idempotency_key' => $data['idempotency_key'], 'payload_hash' => $hash, 'delivery_note' => $deliveryNote,
'received_on' => $data['received_on'], 'status' => 'partial', 'confirmed_at' => now(),
'snapshot' => ['order' => $order->only(['id', 'number']), 'supplier' => $order->snapshot['supplier'], 'receiver' => $user->only(['id', 'name']), 'warehouse' => $warehouse?->only(['id', 'code', 'name']), 'development_only' => true, 'budget_checked' => false],
]);
foreach ($lines as $line) {
$item = $receipt->items()->create($line);
if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) {
StockMovement::query()->create([
'tenant_id' => $user->tenant_id, 'goods_receipt_item_id' => $item->id, 'stock_item_id' => $line['stock_item_id'],
'warehouse_id' => $warehouse?->id, 'moved_by' => $user->id, 'quantity_milli' => $line['accepted_milli'], 'moved_at' => now(),
'equipment_id' => $line['snapshot']['equipment_id'], 'spare_part_id' => $line['snapshot']['spare_part_id'],
'snapshot' => [...$line['snapshot'], 'receipt_id' => $receipt->id, 'order_number' => $order->number, 'delivery_note' => $deliveryNote],
]);
}
}
$progress = $this->progress($order);
$status = $acceptedTotal === 0 ? 'rejected' : ($progress['state'] === 'complete' ? 'complete' : 'partial');
// Complete the reference/status before exposing this append-only confirmation.
DB::table('goods_receipts')->where('id', $receipt->id)->update(['number' => sprintf('REC-%s-%06d', now()->format('Y'), $receipt->id), 'status' => $status]);
$receipt->refresh();
$receipt->load('items');
$this->audit->audit($user, 'receipt.confirmed', $requisition, null, ['receipt' => $receipt->toArray(), 'order_progress' => $progress]);
return $receipt;
});
}
}