285 lines
14 KiB
PHP
285 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tempo\Purchasing\SgfCandidate;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Identity\Services\Permissions;
|
|
use App\Modules\Purchasing\BudgetAdapter;
|
|
use App\Shared\Context\CompanyContext;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Tempo\Purchasing\Internal\InternalRequisitions;
|
|
use Tempo\Purchasing\Internal\RequisitionPort;
|
|
|
|
final class Port implements RequisitionPort
|
|
{
|
|
private array $scope;
|
|
|
|
public function __construct(private User $actor, private string $originKey = '')
|
|
{
|
|
$ctx = app(CompanyContext::class);
|
|
$this->scope = $ctx->identifiers() + ['unit_id' => $ctx->unitId()];
|
|
}
|
|
|
|
public function actorId(): int
|
|
{
|
|
return $this->actor->id;
|
|
}
|
|
|
|
public function scope(): array
|
|
{
|
|
return $this->scope;
|
|
}
|
|
|
|
public function query(string $table): Builder
|
|
{
|
|
return DB::table('ir_'.$table)->where($this->scope);
|
|
}
|
|
|
|
public function atomic(callable $work): mixed
|
|
{
|
|
BudgetAdapter::guard();
|
|
abort_unless(config('purchasing-internal.candidate_enabled') === true, 404);
|
|
|
|
return DB::transaction(function () use ($work) {
|
|
$s = $this->scope;
|
|
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
|
|
$this->actor = User::query()->lockForUpdate()->findOrFail($this->actor->id);
|
|
$ctx = app(CompanyContext::class);
|
|
abort_unless(auth()->id() === $this->actor->id && $ctx->userId() === $this->actor->id && $s['unit_id'] !== null
|
|
&& $ctx->identifiers() + ['unit_id' => $ctx->unitId()] === $s, 409, 'Contexto alterado.');
|
|
$ctx->selectFor($this->actor->id, $s['tenant_id'], $s['company_id'], $s['unit_id']);
|
|
abort_unless(request()->header('X-Purchasing-Context') === 'procurement.internal', 403, 'Escolha Compras internas.');
|
|
if (request()->hasSession() && request()->is('api/*')) {
|
|
abort_unless(hash_equals(request()->session()->get('company_context.token', ''), (string) request()->header('X-SGF-Context')), 409, 'O contexto mudou.');
|
|
}
|
|
|
|
return $work();
|
|
});
|
|
}
|
|
|
|
public function has(string $right): bool
|
|
{
|
|
$s = $this->scope;
|
|
|
|
return app(Permissions::class)->has($this->actor->id, $s['tenant_id'], $s['company_id'], 'purchasing_requisitions.'.$right, $s['unit_id']);
|
|
}
|
|
|
|
public function setup(): array
|
|
{
|
|
$row = $this->query('policies')->first();
|
|
abort_unless($row, 422, 'Configure a política e os cadastros autorizados.');
|
|
|
|
return json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR) + ['revision' => (int) $row->version];
|
|
}
|
|
|
|
private function department(): ?string
|
|
{
|
|
foreach ($this->setup()['members'] as $member) {
|
|
if ($member['user_id'] === $this->actor->id) {
|
|
return $member['department_ref'];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function allowedCentres(): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->setup()['centres'] as $c) {
|
|
if ($this->has('configure') || $c['department_ref'] === $this->department()) {
|
|
$name = DB::table('cb_centres')->where($this->scope)->where('id', $c['id'])->value('code');
|
|
$out[] = $c + ['label' => $name ?? 'Centro configurado'];
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
public function configurationSources(): array
|
|
{
|
|
if (! $this->has('configure')) {
|
|
return [];
|
|
}
|
|
$s = $this->scope;
|
|
|
|
return ['centres' => DB::table('cb_centres')->where($s)->get(['id', 'code'])->all(),
|
|
'members' => DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')->where('m.tenant_id', $s['tenant_id'])->where('m.company_id', $s['company_id'])
|
|
->whereExists(function ($q) use ($s) {
|
|
$q->selectRaw('1')->from('permission_grants as g')->whereColumn('g.user_id', 'u.id')->where('g.tenant_id', $s['tenant_id'])->where('g.company_id', $s['company_id'])->where('g.unit_id', $s['unit_id'])->where('g.permission', 'unit.view');
|
|
})->get(['u.id', 'u.name'])->all()];
|
|
}
|
|
|
|
public function authorize(string $action, ?array $record): void
|
|
{
|
|
abort_unless($this->has('view'), 403, 'Consulta não autorizada.');
|
|
if ($action !== 'configure') {
|
|
abort_unless($this->setup()['enabled'], 403, 'Opção interna não configurada para o produto.');
|
|
}
|
|
if ($record !== null) {
|
|
abort_unless($record['work_context'] === 'procurement.internal', 403);
|
|
$all = $this->has('configure') || $this->has('approve.procurement') || $this->has('approve.finance');
|
|
// Scoped view is sufficient for reading; it never grants mutation rights.
|
|
// load()/query() and atomic() retain tenant, company, unit and option checks.
|
|
$visible = $action === 'view' || $all || $record['requester_id'] === $this->actor->id || ($this->has('approve.department_head') && $record['department_ref'] === $this->department());
|
|
abort_unless($visible, 404);
|
|
}
|
|
if (in_array($action, ['save', 'submit'], true)) {
|
|
abort_unless($this->has('prepare') && $this->department() !== null, 403);
|
|
if ($record !== null) {
|
|
abort_unless($record['requester_id'] === $this->actor->id, 403);
|
|
}
|
|
} elseif ($action === 'configure') {
|
|
abort_unless($this->has('configure'), 403);
|
|
} elseif ($action === 'decide') {
|
|
abort_unless($this->has('approve.department_head') || $this->has('approve.procurement') || $this->has('approve.finance'), 403);
|
|
} elseif (str_starts_with($action, 'step:')) {
|
|
$role = substr($action, 5);
|
|
abort_unless($this->has('approve.'.$role) && ($role !== 'department_head' || $record['department_ref'] === $this->department()), 403);
|
|
}
|
|
}
|
|
|
|
public function load(int $id): array
|
|
{
|
|
$row = $this->query('requests')->where('id', $id)->lockForUpdate()->first();
|
|
abort_unless($row, 404);
|
|
$r = (array) $row;
|
|
foreach (['data', 'approval_snapshot', 'reservation'] as $field) {
|
|
$r[$field] = $r[$field] === null ? null : json_decode($r[$field], true, 64, JSON_THROW_ON_ERROR);
|
|
}
|
|
foreach (['id', 'requester_id', 'version', 'subtotal_minor', 'tax_minor', 'total_minor', 'cost_center_id'] as $field) {
|
|
$r[$field] = (int) $r[$field];
|
|
}
|
|
$r['steps'] = $this->query('steps')->where('request_id', $id)->orderBy('position')->get()->map(fn ($s) => (array) $s)->all();
|
|
$r['has_equipment'] = count(array_filter($r['data']['items'], fn ($i) => $i['purchase_type'] === 'equipment')) > 0;
|
|
|
|
return $r;
|
|
}
|
|
|
|
public function draft(array $input, ?int $id): array
|
|
{
|
|
$data = Validator::make($input, [
|
|
'cost_center_id' => 'required|integer|min:1', 'priority' => 'required|in:normal,high,urgent', 'needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'justification' => 'required|string|min:10|max:5000',
|
|
'items' => 'required|array|min:1|max:50', 'items.*' => 'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate', 'items.*.description' => 'required|string|max:255', 'items.*.category' => 'required|string|max:100', 'items.*.purchase_type' => 'required|in:consumable,service,other',
|
|
'items.*.unit' => 'required|string|max:30', 'items.*.quantity' => ['required', 'regex:/^\d{1,7}(\.\d{1,3})?$/D', 'numeric', 'min:0.001', 'max:1000000'],
|
|
'items.*.unit_price' => ['required', 'regex:/^\d{1,9}(\.\d{1,2})?$/D', 'numeric', 'min:0.01', 'max:100000000'],
|
|
'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/D', 'numeric', 'between:0,100'],
|
|
])->validate();
|
|
abort_if(array_diff(array_keys($input), array_keys($data)) !== [], 422, 'Campo protegido.');
|
|
$department = $this->department();
|
|
$mapped = false;
|
|
foreach ($this->setup()['centres'] as $centre) {
|
|
if ($centre['id'] === (int) $data['cost_center_id'] && $centre['department_ref'] === $department) {
|
|
$mapped = true;
|
|
}
|
|
}
|
|
abort_unless($mapped && DB::table('cb_centres')->where($this->scope)->where('id', $data['cost_center_id'])->exists(), 403, 'Centro fora do cadastro autorizado.');
|
|
$subtotal = 0;
|
|
$tax = 0;
|
|
foreach ($data['items'] as &$line) {
|
|
$line = [...$line, ...InternalRequisitions::line($line['quantity'], $line['unit_price'], $line['tax_rate'])];
|
|
$subtotal += $line['subtotal_minor'];
|
|
$tax += $line['tax_minor'];
|
|
}
|
|
unset($line);
|
|
abort_if($subtotal + $tax > 100000000000000, 422, 'Total excede o limite suportado.');
|
|
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
|
if ($id === null) {
|
|
$old = $this->query('requests')->where('origin_key', $this->originKey)->first();
|
|
if ($old) {
|
|
abort_unless((int) $old->requester_id === $this->actor->id && hash_equals($old->creation_hash, $hash), 409, 'Origem já registada com outros dados.');
|
|
|
|
return $this->load($old->id);
|
|
}
|
|
}
|
|
$values = ['cost_center_id' => $data['cost_center_id'], 'department_ref' => $department, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()];
|
|
if ($id === null) {
|
|
$id = DB::table('ir_requests')->insertGetId($this->scope + $values + ['requester_id' => $this->actor->id, 'number' => 'IR-'.now()->year.'-'.$this->originKey, 'work_context' => 'procurement.internal', 'origin_key' => $this->originKey, 'creation_hash' => $hash, 'created_at' => now()]);
|
|
} else {
|
|
$old = $this->load($id);
|
|
$this->query('requests')->where('id', $id)->update($values + ['version' => $old['version'] + 1]);
|
|
}
|
|
|
|
return $this->event($id, 'draft.saved');
|
|
}
|
|
|
|
public function policy(array $record): array
|
|
{
|
|
$policy = $this->setup();
|
|
abort_unless($this->department() === $record['department_ref'], 422, 'A atribuição ao departamento mudou; reveja o rascunho.');
|
|
abort_unless($record['data']['needed_by'] >= today()->toDateString(), 422, 'Actualize a data pretendida.');
|
|
$mapped = false;
|
|
foreach ($policy['centres'] as $c) {
|
|
if ($c['id'] === $record['cost_center_id'] && $c['department_ref'] === $record['department_ref']) {
|
|
$mapped = true;
|
|
}
|
|
}
|
|
abort_unless($mapped, 422, 'A associação do centro mudou; reveja o rascunho.');
|
|
|
|
return array_intersect_key($policy, array_flip(['revision', 'first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional', 'budget_required']));
|
|
}
|
|
|
|
public function reserve(array $record): ?array
|
|
{
|
|
$origin = DB::table('cb_origins')->where($this->scope)->where('reference', 'internal-requisition:'.$record['origin_key'])->first();
|
|
$id = $origin?->id ?? DB::table('cb_origins')->insertGetId($this->scope + ['cost_center_id' => $record['cost_center_id'], 'required_kind' => 'operating', 'reference' => 'internal-requisition:'.$record['origin_key']]);
|
|
$result = app(BudgetAdapter::class)->record($this->actor, (int) $id, 'reserve', $record['total_minor'], $record['subtotal_minor']);
|
|
abort_if($result === null && $this->setup()['budget_required'], 422, 'Dotação não configurada; nenhuma submissão ou reserva foi gravada.');
|
|
// Persisted atomically with submission; no intermediate root mutation/version.
|
|
$this->pendingReservation = ['origin_id' => (int) $id, 'result' => $result];
|
|
|
|
return $result;
|
|
}
|
|
|
|
private array $pendingReservation = [];
|
|
|
|
public function submitted(array $record, array $policy, array $roles): array
|
|
{
|
|
foreach ($roles as $i => $role) {
|
|
DB::table('ir_steps')->insert($this->scope + ['request_id' => $record['id'], 'position' => $i + 1, 'role' => $role]);
|
|
}
|
|
$this->query('requests')->where('id', $record['id'])->update(['status' => 'pending', 'version' => $record['version'] + 1, 'approval_snapshot' => json_encode($policy, JSON_THROW_ON_ERROR), 'budget_origin_id' => $this->pendingReservation['origin_id'], 'reservation' => json_encode($this->pendingReservation['result'], JSON_THROW_ON_ERROR), 'updated_at' => now()]);
|
|
|
|
return $this->event($record['id'], 'submitted');
|
|
}
|
|
|
|
public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array
|
|
{
|
|
$this->query('steps')->where('id', $step['id'])->update(['decision' => $decision, 'approver_id' => $this->actor->id, 'comment' => $comment, 'decided_at' => now()]);
|
|
$this->query('requests')->where('id', $record['id'])->update(['status' => $status, 'version' => $record['version'] + 1, 'updated_at' => now()]);
|
|
|
|
return $this->event($record['id'], 'approval.'.$decision);
|
|
}
|
|
|
|
public function release(array $record): void
|
|
{
|
|
app(BudgetAdapter::class)->record($this->actor, (int) $record['budget_origin_id'], 'release');
|
|
}
|
|
|
|
private function event(int $id, string $action): array
|
|
{
|
|
$record = $this->load($id);
|
|
DB::table('ir_events')->insert($this->scope + ['request_id' => $id, 'version' => $record['version'], 'actor_id' => $this->actor->id, 'action' => $action, 'payload' => json_encode($record, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
|
|
|
return $record;
|
|
}
|
|
|
|
public function replay(string $key, string $hash): ?array
|
|
{
|
|
$old = $this->query('commands')->where(['actor_id' => $this->actor->id, 'operation_key' => $key])->first();
|
|
if (! $old) {
|
|
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('ir_commands')->insert($this->scope + ['actor_id' => $this->actor->id, 'operation_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
|
}
|
|
}
|