199 lines
13 KiB
PHP
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', 'equipment']);
|
|
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;
|
|
});
|
|
}
|
|
}
|