Files
compras/app/Services/InvoiceWorkflow.php
T

137 lines
8.5 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\GoodsReceiptItem;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\User;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class InvoiceWorkflow
{
private function access(User $user): void
{
abort_unless(auth()->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<array<string,mixed>> $inputs
* @return list<array<string,mixed>>
*/
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<string,mixed> $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);
});
}
}