301 lines
20 KiB
PHP
301 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\AwardCancellation;
|
|
use App\Models\Equipment;
|
|
use App\Models\ProposalEvaluation;
|
|
use App\Models\PurchaseAward;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Models\Quotation;
|
|
use App\Models\QuotationItem;
|
|
use App\Models\Requisition;
|
|
use App\Models\SparePart;
|
|
use App\Models\Supplier;
|
|
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);
|
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
|
$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->supportedItems($requisition);
|
|
$this->sourceApproved($requisition, $rules);
|
|
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
|
|
$result = $this->checkComparison($user, $requisition, $quote);
|
|
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
|
|
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
|
|
$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'],
|
|
'investment_snapshot' => $investment,
|
|
]);
|
|
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
|
|
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);
|
|
$user = app(CurrentActor::class)->lock($user);
|
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
|
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
|
$this->assertNoPendingCancellation($award);
|
|
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]);
|
|
if ($decision === 'rejected') {
|
|
app(BudgetWorkflow::class)->record($user, $requisition, 'release');
|
|
}
|
|
$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);
|
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
|
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;
|
|
}
|
|
$this->assertNoPendingCancellation($award);
|
|
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,
|
|
'equipment_id' => $source->equipment_id, 'spare_part_id' => $source->spare_part_id,
|
|
];
|
|
}
|
|
$validUntil = $quote->attributesToArray()['valid_until'] ?? null;
|
|
$deliveryDate = $award->attributesToArray()['delivery_date'] ?? null;
|
|
if (! is_string($validUntil) || ! is_string($deliveryDate)) {
|
|
throw ValidationException::withMessages(['delivery_date' => 'As datas da proposta e da entrega devem estar completas antes da emissão.']);
|
|
}
|
|
$snapshot = [
|
|
'development_only' => true, 'budget_checked' => false,
|
|
'budget_control' => app(BudgetWorkflow::class)->record($user, $requisition, 'commit', $quote->total_minor, $quote->subtotal_minor),
|
|
'investment_rules' => $award->investment_snapshot,
|
|
'equipment_requests' => $requisition->items()->where('purchase_type', 'equipment')->get()->map(fn ($line) => $line->only(['id', 'description', 'production_area']))->all(),
|
|
'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' => $validUntil],
|
|
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
|
|
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
|
|
'estimate_approvals' => $requisition->steps()->get()->toArray(),
|
|
'final_approvals' => $award->approvals()->get()->toArray(),
|
|
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
|
|
'equipment_context' => $requisition->items()->whereNotNull('equipment_id')->get()->map(fn ($line) => ['requisition_item_id' => $line->id, 'equipment' => Equipment::query()->findOrFail($line->equipment_id)->toArray(), 'spare_part' => SparePart::query()->findOrFail($line->spare_part_id)->toArray(), 'downtime_started_at' => $line->downtime_started_at])->all(),
|
|
];
|
|
$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->supportedItems($requisition);
|
|
$this->sourceApproved($requisition, $rules);
|
|
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($award->quotation_id);
|
|
if (app(InvestmentWorkflow::class)->check($requisition, $quote) !== $award->investment_snapshot) {
|
|
throw ValidationException::withMessages(['investment' => 'A regra de investimento ou a base técnica/orçamental mudou. As aprovações anteriores não autorizam uma emissão com outra base.']);
|
|
}
|
|
$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
|
|
{
|
|
// Also coordinate with supplier reviews, which lock supplier rows without the company mutex.
|
|
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
|
|
Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
|
|
QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
|
|
ProposalEvaluation::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
|
|
User::query()->where('tenant_id', $user->tenant_id)->orderBy('id')->lockForUpdate()->get();
|
|
$requisition->items()->orderBy('id')->lockForUpdate()->get();
|
|
$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, bool $equipment = false): array
|
|
{
|
|
$roles = ['department_head'];
|
|
if ($amount > $rules['first_limit_minor']) {
|
|
$roles[] = 'procurement';
|
|
}
|
|
if ($amount > $rules['second_limit_minor'] || $equipment) {
|
|
$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, $requisition->items()->where('purchase_type', 'equipment')->exists());
|
|
$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->decided_at === null || $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, $requisition->items()->where('purchase_type', 'equipment')->exists());
|
|
$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)->lockForUpdate()->find($step->approver_id);
|
|
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $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()->where('purchase_type', 'equipment')->exists()) {
|
|
app(InvestmentWorkflow::class)->requireCurrent();
|
|
}
|
|
foreach ($requisition->items()->get() as $line) {
|
|
if (! in_array($line->purchase_type, ['consumable', 'service', 'other', 'spare_part', 'equipment']) || ($line->purchase_type === 'spare_part' && (! $line->equipment_id || ! $line->spare_part_id))) {
|
|
throw ValidationException::withMessages(['purchase_type' => 'Tipo não suportado ou peça sem equipamento/artigo associado. Complete o cadastro antes da compra.']);
|
|
}
|
|
app(EquipmentWorkflow::class)->line($line->toArray(), $requisition->department_id, $requisition->priority);
|
|
}
|
|
}
|
|
|
|
private function assertNoPendingCancellation(PurchaseAward $award): void
|
|
{
|
|
if (AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists()) {
|
|
throw ValidationException::withMessages(['cancellation' => 'Existe pedido de cancelamento pendente. Aguarde a decisão antes de aprovar ou emitir.']);
|
|
}
|
|
}
|
|
}
|