id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403); } private function invalid(string $message): never { throw ValidationException::withMessages(['invoice' => $message]); } /** @param list> $inputs * @return list> */ private function lines(PurchaseOrder $order, array $inputs): array { $sources = $order->items()->get()->keyBy('id'); $seen = []; $lines = []; foreach ($inputs as $input) { $source = $sources->get($input['purchase_order_item_id']); if (! $source || in_array($source->id, $seen, true)) { $this->invalid('Linhas distintas e pertencentes à ordem são obrigatórias.'); } $seen[] = $source->id; $quantity = ReceiptWorkflow::milli((string) $input['quantity']); $previous = InvoiceItem::query()->where('purchase_order_item_id', $source->id)->whereIn('invoice_id', Invoice::query()->where('status', 'validated')->select('id'))->sum('quantity_milli'); $accepted = GoodsReceiptItem::query()->where('purchase_order_item_id', $source->id)->sum('accepted_milli'); if ($quantity <= 0 || (int) $previous + $quantity > (int) $accepted || (int) $previous + $quantity > ReceiptWorkflow::milli($source->quantity)) { $this->invalid('Quantidade superior à recepção aceite ainda não facturada. Serviços também exigem aceitação.'); } if (BigDecimal::of((string) $input['unit_price'])->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $input['tax_rate'])->compareTo((string) $source->tax_rate) !== 0 || trim((string) $input['unit']) !== $source->unit) { $this->invalid('Preço, unidade e IVA devem coincidir exactamente com a ordem. Não há tolerância ou excepção configurada.'); } // Cumulative rounding ensures partial invoices sum exactly to the frozen order. $subtotal = fn (int $milli) => BigDecimal::of($milli)->multipliedBy($source->unit_price_minor)->dividedBy(1000, 0, RoundingMode::HalfUp)->toInt(); $tax = fn (int $minor) => BigDecimal::of($minor)->multipliedBy((string) $source->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt(); $before = $subtotal((int) $previous); $after = $subtotal((int) $previous + $quantity); $lines[] = ['tenant_id' => $order->tenant_id, 'purchase_order_item_id' => $source->id, 'quantity_milli' => $quantity, 'subtotal_minor' => $after - $before, 'tax_minor' => $tax($after) - $tax($before), 'snapshot' => $source->only(['description', 'unit', 'unit_price_minor', 'tax_rate'])]; } if ($lines === []) { $this->invalid('Indique pelo menos uma linha.'); } return $lines; } /** @param array $data */ public function register(User $user, PurchaseOrder $order, array $data): Invoice { $this->access($user); abort_unless($order->tenant_id === $user->tenant_id, 403); return DB::transaction(function () use ($user, $order, $data) { app(ConflictGuard::class)->lockCompany($user); $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]); $order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id); $request = Requisition::query()->findOrFail($order->requisition_id); app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user); $number = Str::upper(Str::squish($data['number'])); if ($number === '' || Invoice::query()->where('supplier_id', $order->supplier_id)->where('number', $number)->exists()) { $this->invalid('Número de factura vazio ou já registado para este fornecedor.'); } if ($order->status !== 'issued' || $order->currency !== 'MZN' || substr($data['issued_on'], 0, 4) !== now()->format('Y') || $data['issued_on'] > today()->format('Y-m-d') || $data['issued_on'] < substr((string) $order->getRawOriginal('issued_at'), 0, 10)) { $this->invalid('Factura deve referir ordem emitida em MZN, com data válida no exercício actual e posterior à emissão.'); } $lines = $this->lines($order, $data['items']); $subtotal = array_sum(array_column($lines, 'subtotal_minor')); $tax = array_sum(array_column($lines, 'tax_minor')); foreach (['subtotal' => $subtotal, 'tax' => $tax, 'total' => $subtotal + $tax] as $key => $expected) { if (BigDecimal::of((string) $data[$key])->multipliedBy(100)->compareTo($expected) !== 0) { $this->invalid('Totais da factura divergem da conferência com a ordem e recepção. Verifique os arredondamentos das facturas parciais.'); } } $invoice = Invoice::query()->create(['tenant_id' => $user->tenant_id, 'purchase_order_id' => $order->id, 'supplier_id' => $order->supplier_id, 'created_by' => $user->id, 'number' => $number, 'issued_on' => $data['issued_on'], 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'snapshot' => ['supplier' => $order->snapshot['supplier'], 'order_number' => $order->number, 'author' => $user->only(['id', 'name']), 'currency' => 'MZN', 'development_only' => true, 'matching_tolerance' => 'zero', 'rounding' => 'cumulative_half_up']]); $invoice->items()->createMany($lines); app(RequisitionWorkflow::class)->audit($user, 'invoice.created', $request, null, $invoice->load('items')->toArray()); return $invoice; }); } public function validate(User $user, Invoice $invoice): void { $this->access($user); abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403); DB::transaction(function () use ($user, $invoice) { app(ConflictGuard::class)->lockCompany($user); $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]); $invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id); if ($invoice->status === 'validated') { return; } $order = PurchaseOrder::query()->lockForUpdate()->findOrFail($invoice->purchase_order_id); $request = Requisition::query()->findOrFail($order->requisition_id); app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user); if (substr((string) $invoice->issued_on, 0, 4) !== now()->format('Y')) { $this->invalid('Conferência entre exercícios não suportada.'); } $stored = $invoice->items()->orderBy('id')->get(); $inputs = []; foreach ($stored as $line) { $inputs[] = ['purchase_order_item_id' => $line->purchase_order_item_id, 'quantity' => (string) BigDecimal::of($line->quantity_milli)->dividedBy(1000, 3), 'unit' => $line->snapshot['unit'], 'unit_price' => (string) BigDecimal::of($line->snapshot['unit_price_minor'])->dividedBy(100, 2), 'tax_rate' => $line->snapshot['tax_rate']]; } $current = $this->lines($order, $inputs); foreach ($current as $index => $line) { if ($line['subtotal_minor'] !== $stored[$index]->subtotal_minor || $line['tax_minor'] !== $stored[$index]->tax_minor) { $this->invalid('Conferência alterada por outra factura; a validação está bloqueada.'); } } $adapter = app(CommonBudget::class); $effect = $adapter->execute(fn () => $adapter->for($user)->consume($request->id, $invoice->id, (int) $invoice->total_minor, (int) $invoice->subtotal_minor)); $invoice->update(['status' => 'validated', 'validated_by' => $user->id, 'validated_at' => now()]); app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id] + $effect); }); } }