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

183 lines
8.1 KiB
PHP

<?php
namespace App\Services;
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\RequisitionPort;
final class InternalRequisitionPort implements RequisitionPort
{
/** @param \Closure(array<string,mixed>,?Requisition):Requisition $save */
public function __construct(private User $actor, private \Closure $save, private ?SupplyRecord $automaticRule = null) {}
private function model(int $id): Requisition
{
return Requisition::query()->whereKey($id)->firstOrFail();
}
public function actorId(): int
{
return $this->actor->id;
}
public function atomic(callable $work): mixed
{
return DB::transaction(function () use ($work) {
app(ConflictGuard::class)->lockCompany($this->actor);
if (app(ReplenishmentRun::class)->tenantId() !== null) {
abort_unless($this->automaticRule !== null, 403);
$this->actor = app(ReplenishmentRun::class)->lockResponsible($this->actor);
return $work();
}
$this->actor = app(CurrentActor::class)->lock($this->actor);
abort_unless(app(OrganizationAccess::class)->permissions($this->actor) !== [], 403);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($this->actor), 403);
if (request()->hasSession() && app(WorkContext::class)->enforced($this->actor)) {
abort_unless(app(WorkContext::class)->active(request()) === 'procurement.internal', 403);
}
return $work();
});
}
public function authorize(string $action, ?array $record): void
{
if ($this->automaticRule !== null) {
abort_unless($action === 'save' && $record === null, 403);
app(AutomaticPurchases::class)->requester($this->actor, $this->automaticRule);
return;
}
if ($record !== null) {
$r = $this->model($record['id']);
if ($action === 'view') {
Requisition::query()->visibleTo($this->actor)->findOrFail($record['id']);
}
abort_unless(($r->work_context_key ?? 'procurement.internal') === 'procurement.internal', 403);
if ($action === 'decide' || str_starts_with($action, 'step:')) {
abort_if(app(ConflictGuard::class)->participationBlocked($this->actor, $r), 403);
} elseif ($action !== 'view') {
app(ConflictGuard::class)->assertParticipation($this->actor, $r);
}
}
if (in_array($action, ['save', 'submit'], true)) {
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor), 403);
if ($record !== null) {
abort_unless($record['requester_id'] === $this->actor->id, 403);
}
} elseif ($action === 'decide') {
abort_unless($this->actor->hasFunctions(['department_head', 'procurement', 'finance']), 403);
} elseif (str_starts_with($action, 'step:')) {
$role = substr($action, 5);
abort_unless($this->actor->hasFunctions([$role]) && ($role !== 'department_head' || $this->actor->department_id === $record['department_id']), 403);
}
}
public function load(int $id): array
{
$r = Requisition::query()->lockForUpdate()->findOrFail($id);
return $this->snapshot($r);
}
/** @return array<string,mixed> */
private function snapshot(Requisition $r): array
{
$r->load(['steps' => fn ($q) => $q->orderBy('position'), 'items']);
return $r->toArray() + ['version' => (int) ($r->getAttribute('process_version') ?? 1), 'has_equipment' => $r->items->contains('purchase_type', 'equipment')];
}
public function draft(array $data, ?int $id): array
{
return $this->snapshot(($this->save)($data, $id === null ? null : $this->model($id)));
}
public function policy(array $record): array
{
$r = $this->model($record['id']);
if ($this->actor->department_id !== $r->department_id) {
throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']);
}
foreach ($r->items()->get() as $item) {
app(EquipmentWorkflow::class)->line($item->toArray(), $r->department_id, $r->priority);
}
app(EquipmentWorkflow::class)->assertTechnical($r);
if ($r->needed_by < today()) {
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
}
return Tenant::query()->findOrFail($this->actor->tenant_id)->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
}
public function reserve(array $record): ?array
{
return app(BudgetWorkflow::class)->record($this->actor, $this->model($record['id']), 'reserve', $record['total_minor'], $record['subtotal_minor']);
}
public function submitted(array $record, array $policy, array $roles): array
{
$r = $this->model($record['id']);
foreach ($roles as $position => $role) {
$r->steps()->create(['tenant_id' => $this->actor->tenant_id, 'position' => $position + 1, 'role' => $role]);
}
$values = ['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $policy];
if (Schema::hasColumn('requisitions', 'process_version')) {
$values['process_version'] = $record['version'] + 1;
}
$r->update($values);
app(RequisitionWorkflow::class)->audit($this->actor, 'requisition.submitted', $r, ['status' => 'draft'], ['status' => 'pending', 'rules' => $policy, 'roles' => $roles]);
app(WorkflowNotices::class)->capture($r, $this->actor);
return $this->snapshot($r);
}
public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array
{
$r = $this->model($record['id']);
if ($decision === 'approved') {
app(EquipmentWorkflow::class)->assertTechnical($r);
}
$r->steps()->whereKey($step['id'])->firstOrFail()->update(['decision' => $decision, 'approver_id' => $this->actor->id, 'comment' => $comment, 'decided_at' => now()]);
$values = ['status' => $status];
if (Schema::hasColumn('requisitions', 'process_version')) {
$values['process_version'] = $record['version'] + 1;
}
$r->update($values);
app(RequisitionWorkflow::class)->audit($this->actor, 'approval.'.$decision, $r, ['status' => 'pending'], ['status' => $status, 'step_id' => $step['id'], 'role' => $step['role'], 'comment' => $comment]);
app(WorkflowNotices::class)->capture($r, $this->actor);
app(WorkflowNotices::class)->outcome($r, $step['id']);
return $this->snapshot($r);
}
public function release(array $record): void
{
app(BudgetWorkflow::class)->record($this->actor, $this->model($record['id']), 'release');
}
public function replay(string $key, string $hash): ?array
{
abort_unless(Schema::hasTable('requisition_commands'), 503, 'Instale a migração de recibos antes de usar chaves de operação.');
$old = DB::table('requisition_commands')->where(['tenant_id' => $this->actor->tenant_id, 'actor_id' => $this->actor->id, 'operation_key' => $key])->first();
if ($old === null) {
return null;
}
abort_unless(hash_equals($old->request_hash, $hash), 409, 'Chave usada com outro conteúdo.');
return json_decode($old->result, true, 64, JSON_THROW_ON_ERROR);
}
public function remember(string $key, string $hash, array $result): void
{
DB::table('requisition_commands')->insert(['tenant_id' => $this->actor->tenant_id, 'actor_id' => $this->actor->id, 'operation_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
}