177 lines
7.8 KiB
PHP
177 lines
7.8 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);
|
|
$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()]);
|
|
}
|
|
}
|