multipliedBy(1000)->toScale(0)->toInt(); } public function recordedQuantity(int $lineId, string $column): int { if (! in_array($column, ['received_milli', 'accepted_milli'], true)) { throw new \InvalidArgumentException('Quantidade de recepção desconhecida.'); } $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)->minus(app(InventoryReversals::class)->voidedReceiptQuantity($lineId, $column))->toInt(); } /** @return array{lines: list>, 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'); $returned = app(InventoryDispositions::class)->returnedForOrderLine($item->id); $effective = $accepted - $returned; $lines[] = [ ...$item->only(['id', 'description', 'unit', 'purchase_type']), ...(isset($order->snapshot['catalogue_sources'][$item->requisition_item_id]['stock_item_id']) ? ['stock_item_id' => $order->snapshot['catalogue_sources'][$item->requisition_item_id]['stock_item_id']] : []), 'ordered_milli' => $ordered, 'received_milli' => $received, 'accepted_milli' => $accepted, 'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $effective, ...($returned > 0 ? ['supplier_returned_milli' => $returned, 'effective_accepted_milli' => $effective] : []), ]; $complete = $complete && $effective === $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 $data */ public function confirm(User $user, PurchaseOrder $order, array $data): GoodsReceipt { return DB::transaction(function () use ($user, $order, $data) { $this->conflicts->lockCompany($user); $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Warehouse]); $order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id); Gate::forUser($user)->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>}> $requested */ $requested = $data['items']; $supplyMappings = Schema::hasTable('supply_records') ? app(SupplyPlanning::class)->latest('delivery')->where('purchase_order_id', $order->id)->get() : collect(); foreach ($requested as $line) { $mapping = $supplyMappings->first(fn ($record) => $record->data['purchase_order_item_id'] == $line['purchase_order_item_id']); if ($mapping !== null) { if ((! empty($line['stock_item_id']) && (int) $line['stock_item_id'] !== $mapping->stock_item_id) || (int) ($data['warehouse_id'] ?? 0) !== $mapping->warehouse_id) { throw ValidationException::withMessages(['items' => 'Reutilize o artigo e armazém já associados à confirmação desta linha.']); } $line['stock_item_id'] = $mapping->stock_item_id; } if (! empty($order->snapshot['catalogue_sources']) && empty($line['stock_item_id'])) { $orderLine = $order->items()->whereKey($line['purchase_order_item_id'])->first(); if ($orderLine !== null && isset($order->snapshot['catalogue_sources'][$orderLine->requisition_item_id]['stock_item_id'])) { $linkedStock = $order->snapshot['catalogue_sources'][$orderLine->requisition_item_id]['stock_item_id']; if (! is_int($linkedStock) || $linkedStock < 1) { throw new \LogicException('A origem da ordem não contém um identificador de artigo válido.'); } $line['stock_item_id'] = $linkedStock; } } $inputs[] = [ ...(! empty($line['lots']) ? ['lots' => $line['lots']] : []), '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 (Schema::hasTable('supply_records')) { $import = app(SupplyPlanning::class)->latest('import')->where('purchase_order_id', $order->id)->first(); if ($import !== null && $import->state !== 'released') { throw ValidationException::withMessages(['items' => 'A importação ainda não tem libertação aduaneira comprovada. A chegada ou o pagamento não autorizam entrada utilizável.']); } } 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()->where('id', $input['stock_item_id'])->lockForUpdate()->firstOrFail() : null; $catalogueStock = $order->snapshot['catalogue_sources'][$source->requisition_item_id]['stock_item_id'] ?? null; if ($catalogueStock !== null && $stockItem?->id !== $catalogueStock) { throw ValidationException::withMessages(['items' => 'Conserve o artigo de stock preservado na origem desta linha da ordem.']); } $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']) { $movement = 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], ]); $sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']); app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']); app(AutomaticPurchases::class)->signal($user, $line['stock_item_id'], $warehouse->id); } } $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; }); } }