Files
compras/app/Services/RequisitionWorkflow.php
2026-11-02 20:27:11 +02:00

159 lines
9.0 KiB
PHP

<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\CostCenter;
use App\Models\Requisition;
use App\Models\SupplyRecord;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Tempo\Purchasing\Internal\InternalRequisitions;
use Tempo\Purchasing\Internal\RequisitionFailure;
class RequisitionWorkflow
{
private function process(User $user): InternalRequisitions
{
return new InternalRequisitions(new InternalRequisitionPort($user, fn (array $data, ?Requisition $existing) => $this->persistDraft($user, $data, $existing)));
}
/** @param array<string,mixed> $data */
public function save(User $user, array $data, ?Requisition $existing = null): Requisition
{
$key = $data['operation_key'] ?? null;
$version = isset($data['process_version']) ? (int) $data['process_version'] : null;
unset($data['operation_key'], $data['process_version']);
$result = $this->execute($user, 'save', $existing?->id, $version, $key, $data);
$record = Requisition::query()->whereKey($result['id'])->firstOrFail();
if ($existing !== null) {
app(AutomaticPurchases::class)->requisitionChanged($user, $record);
}
return $record;
}
/**
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function execute(User $user, string $action, ?int $id, ?int $version, ?string $key, array $input = []): array
{
try {
return $this->process($user)->command($action, $id, $version, $key, $input);
} catch (RequisitionFailure $error) {
abort_if($error->status !== 422, $error->status, $error->getMessage());
throw ValidationException::withMessages(['requisition' => $error->getMessage()]);
}
}
/** @param array<string, mixed> $data */
private function persistDraft(User $user, array $data, ?Requisition $existing = null, ?SupplyRecord $automaticRule = null): Requisition
{
return DB::transaction(function () use ($user, $data, $existing, $automaticRule) {
app(ConflictGuard::class)->lockCompany($user);
$user = $automaticRule === null ? app(CurrentActor::class)->lock($user) : app(AutomaticPurchases::class)->lockExecutor($user);
$executor = $user;
if ($automaticRule !== null) {
abort_unless($existing === null, 403);
$user = app(AutomaticPurchases::class)->requester($executor, $automaticRule);
} else {
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']);
$calculation = InternalRequisitions::line($line['quantity'], $line['unit_price'], $line['tax_rate']);
$price = $calculation['unit_price_minor'];
$lineSubtotal = $calculation['subtotal_minor'];
$lineTax = $calculation['tax_minor'];
$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))]),
...(Schema::hasColumn('requisitions', 'process_version') ? ['process_version' => $existing ? (int) $requisition->getAttribute('process_version') + 1 : 1] : []),
])->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($executor, $automaticRule !== null ? 'requisition.automatically_created' : ($existing ? 'requisition.updated' : 'requisition.created'), $requisition, $before, $requisition->toArray() + ($automaticRule === null ? [] : ['automatic_rule_id' => $automaticRule->id, 'designated_requester_id' => $user->id]));
return $requisition;
});
}
/** Only an independently activated rule may create a draft on behalf of its designated requester.
* @param array<string,mixed> $data
*/
public function automaticDraft(User $executor, SupplyRecord $rule, array $data): Requisition
{
$process = new InternalRequisitions(new InternalRequisitionPort($executor, fn (array $input, ?Requisition $existing) => $this->persistDraft($executor, $input, $existing, $rule), $rule));
$result = $process->command('save', null, null, null, $data);
return Requisition::query()->whereKey($result['id'])->firstOrFail();
}
public function submit(User $user, Requisition $requisition, ?string $operationKey = null, ?int $version = null): void
{
$this->execute($user, 'submit', $requisition->id, $version, $operationKey);
app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
}
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment, ?string $operationKey = null, ?int $version = null): void
{
$this->execute($user, 'decide', $requisition->id, $version, $operationKey, ['decision' => $decision, 'comment' => $comment]);
app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
}
/** @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' => app(ReplenishmentRun::class)->tenantId() === null ? request()->ip() : null, 'before' => $before, 'after' => $after + (app(ReplenishmentRun::class)->tenantId() === null ? [] : ['execution' => app(ReplenishmentRun::class)->evidence()]),
]);
}
}