id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === 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); $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); $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.'); } } $commit = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'commit')->first(); if (! $commit) { $this->invalid('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.'); } $budget = Budget::query()->lockForUpdate()->findOrFail($commit->budget_id); if (! $budget->approved_by || (int) $budget->year !== now()->year) { $this->invalid('Orçamento não aprovado ou fora do exercício.'); } $amount = (int) ($budget->includes_tax ? $invoice->total_minor : $invoice->subtotal_minor); $remaining = (int) BudgetEntry::query()->where('requisition_id', $request->id)->sum('committed_minor'); if ($amount > $remaining) { $this->invalid('Compromisso insuficiente para a factura.'); } BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => 'invoice-'.$invoice->id, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => now()]); BudgetConsumption::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'invoice_id' => $invoice->id, 'created_by' => $user->id, 'amount_minor' => $amount, 'created_at' => now()]); $invoice->update(['status' => 'validated', 'validated_by' => $user->id, 'validated_at' => now()]); app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id, 'budget_id' => $budget->id, 'consumed_minor' => $amount]); }); } }