152 lines
9.3 KiB
PHP
152 lines
9.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\AuditLog;
|
|
use App\Models\CostCenter;
|
|
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);
|
|
$user = app(CurrentActor::class)->lock($user);
|
|
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
|
|
if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) {
|
|
throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']);
|
|
}
|
|
$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, equipment_id?: int, spare_part_id?: int, production_area?: string, downtime_started_at?: string|null}> $lines */
|
|
$lines = $data['items'];
|
|
$oldLines = $existing ? $requisition->items()->get()->keyBy('id')->all() : [];
|
|
foreach ($lines as $line) {
|
|
$line = app(RequisitionCatalogue::class)->line($user, $line, $oldLines);
|
|
app(EquipmentWorkflow::class)->line($line, (int) $user->department_id, $data['priority']);
|
|
$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,
|
|
...(! empty($line['catalogue_entry_id']) ? ['catalogue_entry_id' => $line['catalogue_entry_id'], 'catalogue_snapshot' => $line['catalogue_snapshot']] : []),
|
|
'description' => $line['description'], 'category' => $line['category'],
|
|
'purchase_type' => $line['purchase_type'], 'quantity' => $line['quantity'], 'unit' => $line['unit'],
|
|
'equipment_id' => $line['equipment_id'] ?? null, 'spare_part_id' => $line['spare_part_id'] ?? null, 'production_area' => $line['production_area'] ?? null, 'downtime_started_at' => $line['downtime_started_at'] ?? null,
|
|
'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,
|
|
...($existing ? [] : ['comparison_policy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($user->tenant_id))]),
|
|
])->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);
|
|
$user = app(CurrentActor::class)->lock($user);
|
|
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
|
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
|
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
|
if ($user->department_id !== $requisition->department_id) {
|
|
throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']);
|
|
}
|
|
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
|
foreach ($requisition->items()->get() as $item) {
|
|
app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority);
|
|
}
|
|
app(EquipmentWorkflow::class)->assertTechnical($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);
|
|
app(BudgetWorkflow::class)->record($user, $requisition, 'reserve', $requisition->total_minor, $requisition->subtotal_minor);
|
|
$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 || $requisition->items()->where('purchase_type', 'equipment')->exists()) {
|
|
$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);
|
|
$user = app(CurrentActor::class)->lock($user);
|
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
|
$requisition->load('steps');
|
|
abort_unless($requisition->canDecide($user), 403);
|
|
if ($decision === 'approved') {
|
|
app(EquipmentWorkflow::class)->assertTechnical($requisition);
|
|
}
|
|
$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]);
|
|
if ($decision === 'rejected') {
|
|
app(BudgetWorkflow::class)->record($user, $requisition, 'release');
|
|
}
|
|
$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,
|
|
]);
|
|
}
|
|
}
|