commit
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-28 12:06:05 +02:00
parent 90a6848721
commit 4d42f083ce
82 changed files with 7538 additions and 76 deletions
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\ConflictDeclaration;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\PurchaseAwardApproval;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Validation\ValidationException;
class ConflictGuard
{
/** Call first inside a transaction, before locking a requisition or supplier. */
public function lockCompany(User $user): void
{
Tenant::query()->whereKey($user->tenant_id)->lockForUpdate()->firstOrFail();
}
public function hasConflict(int $userId, ?int $supplierId, ?int $requisitionId): bool
{
return ConflictDeclaration::query()->where('user_id', $userId)->where('has_conflict', true)
->where(function ($scopes) use ($supplierId, $requisitionId) {
// Supplier-wide statements apply only once that supplier is known.
$scopes->where(function ($supplierScope) use ($supplierId) {
$supplierScope->whereNull('requisition_id')->where('supplier_id', $supplierId ?? 0);
});
if ($requisitionId !== null) {
$scopes->orWhere(function ($processScope) use ($supplierId, $requisitionId) {
$processScope->where('requisition_id', $requisitionId)->where(function ($supplier) use ($supplierId) {
$supplier->whereNull('supplier_id');
if ($supplierId !== null) {
$supplier->orWhere('supplier_id', $supplierId);
}
});
});
}
})->exists();
}
public function participationBlocked(User $user, Requisition $requisition): bool
{
if ($this->hasConflict($user->id, null, $requisition->id)) {
return true;
}
foreach ($this->participants($requisition) as $userId) {
if ($this->hasConflict($userId, null, $requisition->id)) {
return true;
}
}
foreach (Quotation::query()->where('requisition_id', $requisition->id)->pluck('supplier_id') as $supplierId) {
if ($this->processBlocked($requisition, $supplierId, $user)) {
return true;
}
}
return false;
}
public function assertParticipation(User $user, Requisition $requisition): void
{
if ($this->participationBlocked($user, $requisition)) {
throw ValidationException::withMessages(['conflict' => 'Existe um conflito de interesses activo que impede a sua participação neste processo.']);
}
}
/** @return list<int> */
private function participants(Requisition $requisition): array
{
$quotes = Quotation::query()->where('requisition_id', $requisition->id);
$awards = PurchaseAward::query()->where('requisition_id', $requisition->id);
$participants = collect([$requisition->requester_id])
->merge($requisition->steps()->whereNotNull('approver_id')->pluck('approver_id'))
->merge((clone $quotes)->pluck('created_by'))
->merge(ProposalEvaluation::query()->whereIn('quotation_id', (clone $quotes)->select('id'))->pluck('evaluator_id'))
->merge((clone $awards)->pluck('created_by'))
->merge(PurchaseAwardApproval::query()->whereIn('purchase_award_id', (clone $awards)->select('id'))->whereNotNull('approver_id')->pluck('approver_id'))
->merge(AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', [
'requisition.created', 'requisition.updated', 'requisition.submitted',
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
])->pluck('user_id'))
->unique()->values()->all();
$ids = [];
foreach ($participants as $participant) {
$id = filter_var($participant, FILTER_VALIDATE_INT);
if ($id === false) {
throw new \LogicException('Identificador inválido no histórico de intervenientes.');
}
$ids[] = $id;
}
return array_values(array_unique($ids));
}
public function processBlocked(Requisition $requisition, int $supplierId, ?User $actor = null): bool
{
$participants = $this->participants($requisition);
if ($actor !== null) {
$participants[] = $actor->id;
}
foreach (array_unique($participants) as $userId) {
if ($this->hasConflict($userId, $supplierId, $requisition->id)) {
return true;
}
}
return false;
}
public function assertProcess(Requisition $requisition, int $supplierId, User $actor): void
{
if ($this->processBlocked($requisition, $supplierId, $actor)) {
throw ValidationException::withMessages(['conflict' => 'Existe um conflito de interesses activo de um interveniente neste processo com este fornecedor. A operação está impedida, mesmo com declaração de ausência de conflito.']);
}
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\User;
use Brick\Math\BigDecimal;
use Brick\Math\BigRational;
use Brick\Math\Exception\MathException;
use Brick\Math\RoundingMode;
class ProposalComparison
{
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
public function inspect(Requisition $requisition): array
{
$quotes = Quotation::query()->where('requisition_id', $requisition->id)
->with(['supplier', 'items' => fn ($q) => $q->orderBy('id'), 'evaluations' => fn ($q) => $q->orderBy('id')])->orderBy('id')->get();
$guard = app(ConflictGuard::class);
$referenceWeights = $quotes->first()?->weights;
$rows = [];
foreach ($quotes as $quote) {
$reasons = [];
if (! $quote->supplier?->eligible() || $quote->valid_until < today()) {
$reasons[] = 'Fornecedor não elegível ou proposta expirada.';
}
if (! $this->validWeights($quote->weights) || $quote->weights != $referenceWeights) {
$reasons[] = 'As ponderações das propostas não são comparáveis.';
}
if (! $this->validAmounts($quote, $requisition)) {
$reasons[] = 'Moeda, linhas ou valores inconsistentes com a requisição.';
}
if ($guard->processBlocked($requisition, $quote->supplier_id)) {
$reasons[] = 'Conflito de interesses de um interveniente.';
}
$evaluations = $quote->evaluations->filter(fn ($evaluation) => $this->eligibleEvaluator($evaluation, $requisition))->values();
$rows[] = [
...$quote->toArray(), 'id' => $quote->id, 'supplier_id' => $quote->supplier_id,
'weights' => $quote->weights, 'subtotal_minor' => $quote->subtotal_minor, 'delivery_days' => $quote->delivery_days,
'items' => $quote->items->toArray(), 'evaluations' => $quote->evaluations->toArray(),
'eligible' => $reasons === [], 'ineligibility_reasons' => $reasons,
'qualified_evaluations' => $evaluations->toArray(), 'evaluation_count' => $evaluations->count(),
'score' => null, 'score_minor' => null,
];
}
$eligible = array_values(array_filter($rows, fn ($row) => $row['eligible']));
$minimum = null;
$days = null;
foreach ($eligible as $row) {
$minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']);
$days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']);
}
$best = null;
$topIds = [];
foreach ($rows as &$row) {
if ($row['eligible'] && $row['evaluation_count'] >= 2) {
$weights = $row['weights'];
$score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price'])
->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery']));
foreach (['quality', 'history', 'payment'] as $criterion) {
$sum = BigRational::zero();
foreach ($row['qualified_evaluations'] as $evaluation) {
$sum = $sum->plus($evaluation[$criterion]);
}
$score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion]));
}
$minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt();
$row['score_minor'] = $minor;
$row['score'] = $minor / 100;
if ($best === null || $minor > $best) {
$best = $minor;
$topIds = [$row['id']];
} elseif ($minor === $best) {
$topIds[] = $row['id'];
}
}
}
unset($row);
$basis = [];
foreach ($rows as $row) {
$basis[] = [
'quotation' => array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'delivery_days', 'valid_until', 'payment_terms', 'subtotal_minor', 'tax_minor', 'total_minor', 'currency', 'weights'])),
'items' => $row['items'], 'evaluations' => $row['evaluations'],
'qualified_ids' => array_column($row['qualified_evaluations'], 'id'),
'eligible' => $row['eligible'], 'score_minor' => $row['score_minor'],
];
}
$ready = count($eligible) >= 3 && count(array_unique(array_column($eligible, 'supplier_id'))) >= 3;
foreach ($eligible as $row) {
$ready = $ready && $row['evaluation_count'] >= 2;
}
return ['rows' => $rows, 'ready' => $ready, 'top_ids' => $topIds, 'fingerprint' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))];
}
public function eligibleEvaluator(ProposalEvaluation $evaluation, Requisition $requisition): bool
{
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($evaluation->evaluator_id);
if (! $user || ! $evaluation->no_conflict || $user->id === $requisition->requester_id) {
return false;
}
if (! $this->eligibleRole($user, $requisition)) {
return false;
}
foreach (['quality', 'history', 'payment'] as $criterion) {
if (! is_numeric($evaluation->$criterion) || $evaluation->$criterion < 0 || $evaluation->$criterion > 10) {
return false;
}
}
return true;
}
public function eligibleRole(User $user, Requisition $requisition): bool
{
return $user->tenant_id === $requisition->tenant_id &&
(in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) ||
($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id));
}
/** @param array<string, mixed> $weights */
private function validWeights(array $weights): bool
{
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
if (count($weights) !== count($keys) || array_sum($weights) !== 100) {
return false;
}
foreach ($keys as $key) {
if (! isset($weights[$key]) || ! is_int($weights[$key]) || $weights[$key] < 0 || $weights[$key] > 100) {
return false;
}
}
return true;
}
private function validAmounts(Quotation $quote, Requisition $requisition): bool
{
if ($quote->currency !== 'MZN' || $quote->delivery_days < 1 || $quote->subtotal_minor <= 0 || $quote->total_minor > 100000000000000) {
return false;
}
$requested = $requisition->items()->get()->keyBy('id');
if ($requested->isEmpty() || $requested->count() !== $quote->items->count()) {
return false;
}
$subtotal = 0;
$tax = 0;
$seen = [];
try {
foreach ($quote->items as $item) {
$original = $requested->get($item->requisition_item_id);
if (! $original || in_array($item->requisition_item_id, $seen) || $item->unit_price_minor < 1 ||
$item->quantity <= 0 || $item->tax_rate < 0 || $item->tax_rate > 100 ||
! BigDecimal::of($item->quantity)->isEqualTo($original->quantity) ||
! BigDecimal::of($item->tax_rate)->isEqualTo($original->tax_rate)) {
return false;
}
$seen[] = $item->requisition_item_id;
$amount = BigDecimal::of($item->quantity)->multipliedBy($item->unit_price_minor)->toScale(0, RoundingMode::HalfUp)->toInt();
$vat = BigDecimal::of($amount)->multipliedBy($item->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
if ($amount !== $item->subtotal_minor || $vat !== $item->tax_minor || $amount + $vat !== $item->total_minor) {
return false;
}
$subtotal += $amount;
$tax += $vat;
}
} catch (MathException) {
return false;
}
return $subtotal === $quote->subtotal_minor && $tax === $quote->tax_minor && $subtotal + $tax === $quote->total_minor;
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PurchaseWorkflow
{
public function __construct(private ConflictGuard $guard, private ProposalComparison $comparison, private RequisitionWorkflow $audit) {}
/** @param array{quotation_id: int, justification: string, delivery_date: string, delivery_location: string} $data */
public function select(User $user, Requisition $requisition, array $data): PurchaseAward
{
return DB::transaction(function () use ($user, $requisition, $data) {
$this->guard->lockCompany($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
}
$rules = $this->rules($requisition);
$this->sourceApproved($requisition, $rules);
$this->supportedItems($requisition);
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
$result = $this->checkComparison($user, $requisition, $quote);
$award = PurchaseAward::query()->create([
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'quotation_id' => $quote->id,
'created_by' => $user->id, 'justification' => $data['justification'], 'delivery_date' => $data['delivery_date'],
'delivery_location' => $data['delivery_location'], 'status' => 'pending',
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
]);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor);
foreach ($roles as $position => $role) {
$award->approvals()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
}
$this->audit->audit($user, 'award.created', $requisition, null, $award->load('approvals')->toArray());
return $award;
});
}
public function canDecide(User $user, PurchaseAward $award, Requisition $requisition): bool
{
if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || in_array($user->id, [$requisition->requester_id, $award->created_by])) {
return false;
}
$steps = $award->approvals()->get();
$next = $steps->firstWhere('decision', 'pending');
return $next !== null && $next->role === $user->purchasing_role->value &&
($next->role !== 'department_head' || $user->department_id === $requisition->department_id) &&
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
}
public function decide(User $user, PurchaseAward $award, string $decision, ?string $comment): void
{
DB::transaction(function () use ($user, $award, $decision, $comment) {
$this->guard->lockCompany($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
abort_unless($this->canDecide($user, $award, $requisition), 403);
if ($decision === 'approved') {
$this->currentBasis($user, $award, $requisition);
}
$step = $award->approvals()->where('decision', 'pending')->firstOrFail();
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
$status = $decision === 'rejected' ? 'rejected' : ($award->approvals()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
$award->update(['status' => $status]);
$this->audit->audit($user, 'award.'.$decision, $requisition, ['status' => 'pending'], ['award_id' => $award->id, 'status' => $status, 'approval' => $step->toArray()]);
});
}
public function issue(User $user, PurchaseAward $award): PurchaseOrder
{
return DB::transaction(function () use ($user, $award) {
$this->guard->lockCompany($user);
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
// An already committed issue is returned, never re-created or re-snapshotted.
$existing = PurchaseOrder::query()->where('requisition_id', $requisition->id)->first();
if ($existing) {
return $existing;
}
if ($award->status !== 'approved') {
throw ValidationException::withMessages(['approvals' => 'Conclua as aprovações do valor adjudicado antes de emitir a ordem.']);
}
$quote = $this->currentBasis($user, $award, $requisition);
$this->validateFinalApprovals($award, $requisition);
if ($award->delivery_date < today()) {
throw ValidationException::withMessages(['delivery_date' => 'A data de entrega da adjudicação já passou. Não é possível emitir esta ordem.']);
}
$requisition->load(['department', 'costCenter', 'requester']);
$supplier = $quote->supplier;
abort_unless($supplier !== null && $requisition->department !== null && $requisition->costCenter !== null, 409);
$lines = [];
$requested = $requisition->items()->get()->keyBy('id');
foreach ($quote->items()->orderBy('id')->get() as $item) {
$source = $requested->get($item->requisition_item_id);
abort_if($source === null, 409);
$lines[] = [
...$item->only(['requisition_item_id', 'description', 'quantity', 'unit_price_minor', 'tax_rate', 'subtotal_minor', 'tax_minor', 'total_minor']),
...$source->only(['unit', 'category', 'purchase_type']), 'tenant_id' => $user->tenant_id,
];
}
$snapshot = [
'development_only' => true, 'budget_checked' => false,
'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']),
'requisition' => $requisition->only(['id', 'number', 'justification']),
'department' => $requisition->department->only(['id', 'name']),
'cost_center' => $requisition->costCenter->only(['id', 'code', 'name']),
'requester' => $requisition->requester?->only(['id', 'name']),
'supplier' => $supplier->only(['id', 'name', 'nuit', 'contact', 'email', 'phone', 'address']),
'quotation' => $quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days', 'valid_until']),
'award' => $award->only(['id', 'justification', 'delivery_date', 'delivery_location']),
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
'estimate_approvals' => $requisition->steps()->get()->toArray(),
'final_approvals' => $award->approvals()->get()->toArray(),
'decision_matrix' => $award->decision_matrix, 'items' => $lines,
];
$order = PurchaseOrder::query()->create([
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'purchase_award_id' => $award->id,
'supplier_id' => $supplier->id, 'issued_by' => $user->id, 'currency' => $quote->currency,
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
'snapshot' => $snapshot, 'issued_at' => now(),
]);
// Complete the generated reference before this transaction exposes the immutable document.
DB::table('purchase_orders')->where('id', $order->id)->update(['number' => sprintf('OC-%s-%06d', now()->format('Y'), $order->id)]);
$order->refresh();
$order->items()->createMany($lines);
$this->audit->audit($user, 'purchase_order.issued', $requisition, null, $order->load('items')->toArray());
return $order;
});
}
private function currentBasis(User $user, PurchaseAward $award, Requisition $requisition): Quotation
{
$rules = $this->rules($requisition);
$this->sourceApproved($requisition, $rules);
$this->supportedItems($requisition);
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($award->quotation_id);
$result = $this->checkComparison($user, $requisition, $quote);
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
$quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) {
throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);
}
return $quote;
}
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
private function checkComparison(User $user, Requisition $requisition, Quotation $quote): array
{
$this->guard->assertParticipation($user, $requisition);
$this->guard->assertProcess($requisition, $quote->supplier_id, $user);
$result = $this->comparison->inspect($requisition);
if (! $result['ready']) {
throw ValidationException::withMessages(['comparison' => 'São exigidas três propostas elegíveis de fornecedores distintos, com pelo menos duas avaliações elegíveis cada. Excepções sem política aprovada estão bloqueadas.']);
}
if (! in_array($quote->id, $result['top_ids'], true)) {
throw ValidationException::withMessages(['quotation_id' => 'Escolha uma proposta de maior pontuação. A escolha fora do topo exige política de escalão superior ainda não aprovada.']);
}
return $result;
}
/** @return array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} */
private function rules(Requisition $requisition): array
{
$rules = $requisition->getAttribute('approval_snapshot');
if (! is_array($rules) || ! isset($rules['first_limit_minor'], $rules['second_limit_minor'], $rules['approval_includes_tax'], $rules['rules_provisional']) ||
! is_int($rules['first_limit_minor']) || ! is_int($rules['second_limit_minor']) || ! is_bool($rules['approval_includes_tax']) || ! is_bool($rules['rules_provisional']) ||
$rules['first_limit_minor'] < 0 || $rules['second_limit_minor'] < $rules['first_limit_minor']) {
throw ValidationException::withMessages(['policy' => 'O processo não tem regras de aprovação guardadas e válidas. Não é possível presumir limites ou a base de IVA.']);
}
return ['first_limit_minor' => $rules['first_limit_minor'], 'second_limit_minor' => $rules['second_limit_minor'], 'approval_includes_tax' => $rules['approval_includes_tax'], 'rules_provisional' => $rules['rules_provisional']];
}
/** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules
* @return list<string>
*/
private function roles(array $rules, int $amount): array
{
$roles = ['department_head'];
if ($amount > $rules['first_limit_minor']) {
$roles[] = 'procurement';
}
if ($amount > $rules['second_limit_minor']) {
$roles[] = 'finance';
}
return $roles;
}
/** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules */
private function sourceApproved(Requisition $requisition, array $rules): void
{
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $requisition->total_minor : $requisition->subtotal_minor);
$steps = $requisition->steps()->get();
$seen = [$requisition->requester_id];
if ($requisition->status !== 'approved' || $steps->count() !== count($roles)) {
throw ValidationException::withMessages(['approvals' => 'A requisição deve ter todas as aprovações obrigatórias da estimativa, preservadas no histórico.']);
}
foreach ($steps as $index => $step) {
if ($step->decision !== 'approved' || $step->role !== $roles[$index] || $step->approver_id === null || in_array($step->approver_id, $seen, true) ||
! User::query()->where('tenant_id', $requisition->tenant_id)->whereKey($step->approver_id)->exists()) {
throw ValidationException::withMessages(['approvals' => 'O histórico de aprovação da estimativa está incompleto ou inconsistente.']);
}
$seen[] = $step->approver_id;
}
}
private function validateFinalApprovals(PurchaseAward $award, Requisition $requisition): void
{
$rules = $this->rules($requisition);
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $award->total_minor : $award->subtotal_minor);
$steps = $award->approvals()->get();
$seen = [$requisition->requester_id, $award->created_by];
if ($steps->count() !== count($roles)) {
throw ValidationException::withMessages(['approvals' => 'O ciclo de aprovação do valor final está incompleto.']);
}
foreach ($steps as $index => $step) {
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($step->approver_id);
if (! $user || $step->decision !== 'approved' || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
}
$this->guard->assertProcess($requisition, Quotation::query()->findOrFail($award->quotation_id)->supplier_id, $user);
$seen[] = $user->id;
}
}
private function supportedItems(Requisition $requisition): void
{
if ($requisition->items()->whereNotIn('purchase_type', ['consumable', 'service', 'other'])->exists()) {
throw ValidationException::withMessages(['purchase_type' => 'Equipamento e peças exigem regras adicionais ainda não suportadas. A emissão para estes tipos está bloqueada.']);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\Requisition;
use App\Models\Tenant;
use App\Models\User;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RequisitionWorkflow
{
/** @param array<string, mixed> $data */
public function save(User $user, array $data, ?Requisition $existing = null): Requisition
{
return DB::transaction(function () use ($user, $data, $existing) {
app(ConflictGuard::class)->lockCompany($user);
$requisition = $existing ? Requisition::query()->lockForUpdate()->findOrFail($existing->id) : new Requisition;
if ($existing) {
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
app(ConflictGuard::class)->assertParticipation($user, $requisition);
}
$before = $existing ? $requisition->load('items')->toArray() : null;
$items = [];
$subtotal = 0;
$tax = 0;
/** @var list<array{description: string, category: string, purchase_type: string, quantity: string, unit: string, unit_price: string, tax_rate: string}> $lines */
$lines = $data['items'];
foreach ($lines as $line) {
$price = BigDecimal::of($line['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
$lineSubtotal = BigDecimal::of($line['quantity'])->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
$lineTax = BigDecimal::of($lineSubtotal)->multipliedBy($line['tax_rate'])->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
$items[] = [
'tenant_id' => $user->tenant_id,
'description' => $line['description'], 'category' => $line['category'],
'purchase_type' => $line['purchase_type'], 'quantity' => $line['quantity'], 'unit' => $line['unit'],
'unit_price_minor' => $price, 'tax_rate' => $line['tax_rate'],
'subtotal_minor' => $lineSubtotal, 'tax_minor' => $lineTax, 'total_minor' => $lineSubtotal + $lineTax,
];
$subtotal += $lineSubtotal;
$tax += $lineTax;
if ($subtotal + $tax > 100000000000000) {
throw ValidationException::withMessages(['items' => 'O total da requisição ultrapassa o limite suportado de 1.000.000.000.000 MZN.']);
}
}
$requisition->fill([
'tenant_id' => $user->tenant_id, 'requester_id' => $user->id, 'department_id' => $user->department_id,
'cost_center_id' => $data['cost_center_id'], 'priority' => $data['priority'],
'needed_by' => $data['needed_by'], 'justification' => $data['justification'],
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax,
])->save();
if (! $existing) {
$requisition->update(['number' => sprintf('REQ-%s-%06d', now()->format('Y'), $requisition->id)]);
}
$requisition->items()->delete();
$requisition->items()->createMany($items);
$requisition->load('items');
$this->audit($user, $existing ? 'requisition.updated' : 'requisition.created', $requisition, $before, $requisition->toArray());
return $requisition;
});
}
public function submit(User $user, Requisition $requisition): void
{
DB::transaction(function () use ($user, $requisition) {
app(ConflictGuard::class)->lockCompany($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
app(ConflictGuard::class)->assertParticipation($user, $requisition);
if ($requisition->needed_by < today()) {
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
}
$tenant = Tenant::query()->findOrFail($user->tenant_id);
$amount = $tenant->approval_includes_tax ? $requisition->total_minor : $requisition->subtotal_minor;
$roles = ['department_head'];
if ($amount > $tenant->first_limit_minor) {
$roles[] = 'procurement';
}
if ($amount > $tenant->second_limit_minor) {
$roles[] = 'finance';
}
foreach ($roles as $position => $role) {
$requisition->steps()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
}
$snapshot = $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
$requisition->update(['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $snapshot]);
$this->audit($user, 'requisition.submitted', $requisition, ['status' => 'draft'], ['status' => 'pending', 'rules' => $snapshot, 'roles' => $roles]);
});
}
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment): void
{
DB::transaction(function () use ($user, $requisition, $decision, $comment) {
app(ConflictGuard::class)->lockCompany($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
$requisition->load('steps');
abort_unless($requisition->canDecide($user), 403);
$step = $requisition->steps->firstWhere('decision', 'pending');
abort_if($step === null, 409);
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
$status = $decision === 'rejected' ? 'rejected' : ($requisition->steps()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
$requisition->update(['status' => $status]);
$this->audit($user, 'approval.'.$decision, $requisition, ['status' => 'pending'], ['status' => $status, 'step_id' => $step->id, 'role' => $step->role, 'comment' => $comment]);
});
}
/** @param array<string, mixed>|null $before
* @param array<string, mixed> $after
*/
public function audit(User $user, string $action, ?Requisition $requisition, ?array $before, array $after): void
{
AuditLog::query()->create([
'tenant_id' => $user->tenant_id, 'user_id' => $user->id,
'requisition_id' => $requisition?->id, 'action' => $action,
'ip_address' => request()->ip(), 'before' => $before, 'after' => $after,
]);
}
}