This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
@@ -0,0 +1,174 @@
<?php
namespace Tempo\Purchasing\SgfCandidate;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Purchasing\Internal\InternalRequisitions;
use Tempo\Purchasing\Internal\RequisitionFailure;
final class Controller
{
private function project(Port $port, array $record): array
{
$actions = [];
if ($record['status'] === 'draft' && $record['requester_id'] === $port->actorId()) {
try {
$port->authorize('save', $record);
$actions = ['edit', 'submit'];
} catch (HttpException $e) {
if (! in_array($e->getStatusCode(), [403, 404], true)) {
throw $e;
}
}
} elseif (($next = InternalRequisitions::nextStep($record, $port->actorId())) !== null) {
try {
$port->authorize('step:'.$next['role'], $record);
$actions = ['approved'];
$s = $port->scope();
if (app(Permissions::class)->has($port->actorId(), $s['tenant_id'], $s['company_id'], 'purchasing_budget.record', $s['unit_id'])) {
$actions[] = 'rejected';
}
} catch (HttpException $e) {
if (! in_array($e->getStatusCode(), [403, 404], true)) {
throw $e;
}
}
}
return $record + ['allowed_actions' => $actions];
}
private function port(Request $request, string $key = ''): Port
{
$actor = $request->user();
abort_unless($actor instanceof User, 403);
return new Port($actor, $key);
}
public function index(Request $request)
{
$port = $this->port($request);
$data = $port->atomic(function () use ($port) {
if (! $port->query('policies')->exists()) {
$port->authorize('configure', null);
return ['context' => 'procurement.internal', 'scope' => $port->scope(), 'records' => [], 'centres' => [], 'configurationSources' => $port->configurationSources(), 'policy' => null, 'permissions' => ['configure' => true, 'prepare' => false]];
}
$port->authorize('view', null);
$records = [];
foreach ($port->query('requests')->orderByDesc('id')->limit(100)->pluck('id') as $id) {
$record = $port->load((int) $id);
try {
$port->authorize('view', $record);
$records[] = $this->project($port, $record);
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
$policy = $port->setup();
return ['context' => 'procurement.internal', 'scope' => $port->scope(), 'records' => $records, 'centres' => $port->allowedCentres(), 'configurationSources' => $port->configurationSources(), 'policy' => $port->has('configure') ? $policy : array_intersect_key($policy, array_flip(['revision'])),
'permissions' => array_combine(['prepare', 'configure', 'department_head', 'procurement', 'finance'], array_map(fn ($p) => $port->has($p), ['prepare', 'configure', 'approve.department_head', 'approve.procurement', 'approve.finance']))];
});
return response()->json($data)->header('Cache-Control', 'private, no-store');
}
public function show(Request $request, int $id)
{
$port = $this->port($request);
$result = $port->atomic(function () use ($port, $id) {
$record = $port->load($id);
$port->authorize('view', $record);
return ['record' => $this->project($port, $record), 'history' => $port->query('events')->where('request_id', $id)->orderBy('version')->get()];
});
return response()->json($result)->header('Cache-Control', 'private, no-store');
}
public function command(Request $request, string $action, ?int $id = null)
{
$meta = $request->validate(['operation_key' => 'required|uuid', 'version' => ($id === null ? 'nullable' : 'required').'|integer|min:1']);
$port = $this->port($request, $meta['operation_key']);
$input = $request->except(['operation_key', 'version']);
if ($action === 'submit') {
abort_unless($input === [], 422, 'Campo protegido.');
}
if ($action === 'decide') {
$input = Validator::make($input, ['decision' => 'required|in:approved,rejected', 'comment' => 'nullable|required_if:decision,rejected|string|max:2000'])->validate();
}
try {
$result = (new InternalRequisitions($port))->command($action, $id, $meta['version'] ?? null, $meta['operation_key'], $input);
} catch (RequisitionFailure $e) {
return response()->json(['message' => $e->getMessage()], $e->status);
}
return response()->json($result)->header('Cache-Control', 'private, no-store');
}
public function configure(Request $request)
{
$port = $this->port($request);
$data = $request->validate(['version' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'enabled' => 'required|boolean', 'budget_required' => 'required|boolean',
'first_limit_minor' => 'required|integer|min:0|max:100000000000000', 'second_limit_minor' => 'required|integer|min:0|max:100000000000000', 'approval_includes_tax' => 'required|boolean',
'centres' => 'required|array|min:1', 'centres.*.id' => 'required|integer|distinct|min:1', 'centres.*.department_ref' => 'required|string|max:100',
'members' => 'required|array|min:1', 'members.*.user_id' => 'required|integer|distinct|min:1', 'members.*.department_ref' => 'required|string|max:100']);
abort_if(array_diff(array_keys($request->all()), array_keys($data)) !== [], 422, 'Campo protegido.');
$port->atomic(function () use ($port, $data) {
$port->authorize('configure', null);
$scope = $port->scope();
$old = $port->query('policies')->lockForUpdate()->first();
abort_unless((int) ($old->version ?? 0) === $data['version'], 409, 'Política alterada.');
abort_unless($data['second_limit_minor'] >= $data['first_limit_minor'], 422, 'Limites inválidos.');
foreach ($data['centres'] as $c) {
abort_unless(DB::table('cb_centres')->where($scope)->where('id', $c['id'])->exists(), 403, 'Centro não autorizado.');
}
foreach ($data['members'] as $m) {
abort_unless(DB::table('company_memberships')->where(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'user_id' => $m['user_id']])->exists(), 403, 'Pessoa não autorizada.');
abort_unless(app(Permissions::class)->has($m['user_id'], $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']), 403, 'Unidade não autorizada.');
}
$payload = $data;
unset($payload['version']);
$payload['rules_provisional'] = false;
$values = ['version' => $data['version'] + 1, 'actor_id' => $port->actorId(), 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'created_at' => now()];
if ($old) {
$port->query('policies')->update($values);
} else {
DB::table('ir_policies')->insert($scope + $values);
}
DB::table('ir_policy_versions')->insert($scope + $values);
});
return response()->json(['configured' => true, 'version' => $data['version'] + 1]);
}
public function page(Request $request)
{
$request->headers->set('X-Purchasing-Context', 'procurement.internal');
$port = $this->port($request);
$port->atomic(fn () => $port->authorize($port->query('policies')->exists() ? 'view' : 'configure', null));
$selection = $request->session()->get('company_context', []);
$scope = $port->scope();
$contract = ['csrf' => csrf_token(), 'token' => $selection['token'] ?? '', 'scope' => $scope, 'context' => 'procurement.internal', 'actor_id' => $port->actorId(),
'company' => DB::table('companies')->where('id', $scope['company_id'])->value('name'), 'unit' => DB::table('units')->where('id', $scope['unit_id'])->value('name')];
ob_start();
require __DIR__.'/screen.php';
$html = ob_get_clean();
return response($html)->header('Cache-Control', 'private, no-store');
}
public function script(Request $request)
{
return response(file_get_contents(__DIR__.'/screen.mjs'))->header('Content-Type', 'text/javascript')->header('Cache-Control', 'private, no-store');
}
}
@@ -0,0 +1,282 @@
<?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');
$visible = $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()]);
}
}
@@ -0,0 +1,118 @@
<?php
namespace Tempo\Purchasing\SgfCandidate;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Blueprint;
/** Host-owned additive schema, deliberately outside automatic package installation. */
final class Schema
{
public static function install(ConnectionInterface $db): void
{
$schema = $db->getSchemaBuilder();
if ($schema->hasTable('ir_requests')) {
throw new \RuntimeException('Schema já existe; não redefinir dados.');
}
$db->transaction(function () use ($schema, $db) {
if ($db->getDriverName() === 'pgsql') {
$db->statement('ALTER TABLE permission_grants ALTER COLUMN permission TYPE varchar(100)');
$check = $db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
if (! $check || ! preg_match('/^CHECK\s*\((.*)\)$/s', $check->definition, $parts)) {
throw new \RuntimeException('Conferir o catálogo de permissões antes de migrar.');
}
$db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
$db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$parts[1]}) OR (unit_id IS NOT NULL AND permission::text IN ('purchasing_requisitions.view'::text,'purchasing_requisitions.prepare'::text,'purchasing_requisitions.configure'::text,'purchasing_requisitions.approve.department_head'::text,'purchasing_requisitions.approve.procurement'::text,'purchasing_requisitions.approve.finance'::text)))");
}
$schema->create('ir_requests', function (Blueprint $t) {
$t->id();
foreach (['tenant_id', 'company_id', 'unit_id', 'requester_id', 'cost_center_id'] as $c) {
$t->unsignedBigInteger($c);
}
$t->string('work_context', 40)->default('procurement.internal');
$t->uuid('origin_key');
$t->string('creation_hash', 64);
$t->string('number')->nullable();
$t->string('department_ref', 100);
$t->string('status')->default('draft');
$t->unsignedInteger('version')->default(1);
$t->bigInteger('subtotal_minor');
$t->bigInteger('tax_minor');
$t->bigInteger('total_minor');
$t->jsonb('data');
$t->jsonb('approval_snapshot')->nullable();
$t->unsignedBigInteger('budget_origin_id')->nullable();
$t->jsonb('reservation')->nullable();
$t->timestamps();
$t->unique(['tenant_id', 'company_id', 'unit_id', 'origin_key']);
$t->unique(['id', 'tenant_id', 'company_id', 'unit_id']);
});
foreach (['steps', 'events', 'commands', 'policies', 'policy_versions'] as $kind) {
$schema->create('ir_'.$kind, function (Blueprint $t) use ($kind) {
$t->id();
foreach (['tenant_id', 'company_id', 'unit_id'] as $c) {
$t->unsignedBigInteger($c);
}
if (in_array($kind, ['steps', 'events'], true)) {
$t->unsignedBigInteger('request_id');
$t->foreign(['request_id', 'tenant_id', 'company_id', 'unit_id'])->references(['id', 'tenant_id', 'company_id', 'unit_id'])->on('ir_requests')->restrictOnDelete();
}
if ($kind === 'steps') {
$t->unsignedInteger('position');
$t->string('role');
$t->string('decision')->default('pending');
$t->unsignedBigInteger('approver_id')->nullable();
$t->text('comment')->nullable();
$t->timestamp('decided_at')->nullable();
$t->unique(['request_id', 'position']);
$t->unique(['request_id', 'approver_id']);
} elseif ($kind === 'commands') {
$t->unsignedBigInteger('actor_id');
$t->uuid('operation_key');
$t->string('request_hash', 64);
$t->jsonb('result');
$t->timestamp('created_at');
$t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'operation_key']);
} else {
$t->unsignedInteger('version');
$t->unsignedBigInteger('actor_id');
$t->jsonb('payload');
$t->timestamp('created_at');
if ($kind === 'events') {
$t->string('action');
$t->unique(['request_id', 'version']);
} else {
$t->unique($kind === 'policies' ? ['tenant_id', 'company_id', 'unit_id'] : ['tenant_id', 'company_id', 'unit_id', 'version']);
}
}
});
}
if ($db->getDriverName() === 'pgsql') {
$db->statement("ALTER TABLE ir_requests ADD CONSTRAINT ir_internal_context CHECK (work_context='procurement.internal')");
foreach (['ir_events', 'ir_commands', 'ir_policy_versions'] as $table) {
$db->unprepared("CREATE FUNCTION {$table}_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Append-only history'; END; \$\$; CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION {$table}_immutable();");
}
$db->unprepared(<<<'SQL'
CREATE FUNCTION ir_origin_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Preserve requisition origin'; END IF;
IF (NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.work_context,NEW.origin_key,NEW.requester_id,NEW.creation_hash) IS DISTINCT FROM (OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.work_context,OLD.origin_key,OLD.requester_id,OLD.creation_hash) THEN RAISE EXCEPTION 'Immutable origin'; END IF;
IF OLD.status <> 'draft' AND (NEW.data,NEW.subtotal_minor,NEW.tax_minor,NEW.total_minor,NEW.cost_center_id,NEW.department_ref,NEW.approval_snapshot,NEW.budget_origin_id,NEW.reservation) IS DISTINCT FROM (OLD.data,OLD.subtotal_minor,OLD.tax_minor,OLD.total_minor,OLD.cost_center_id,OLD.department_ref,OLD.approval_snapshot,OLD.budget_origin_id,OLD.reservation) THEN RAISE EXCEPTION 'Preserve submitted snapshot'; END IF;
IF NEW.version <= OLD.version THEN RAISE EXCEPTION 'Version must advance'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER ir_origin_guard BEFORE UPDATE OR DELETE ON ir_requests FOR EACH ROW EXECUTE FUNCTION ir_origin_guard();
CREATE FUNCTION ir_step_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Preserve approval steps'; END IF;
IF OLD.decision <> 'pending' THEN RAISE EXCEPTION 'Final decision immutable'; END IF;
IF NEW.approver_id = (SELECT requester_id FROM ir_requests WHERE id=NEW.request_id) THEN RAISE EXCEPTION 'Independent identity required'; END IF;
IF (NEW.request_id,NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.position,NEW.role) IS DISTINCT FROM (OLD.request_id,OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.position,OLD.role) THEN RAISE EXCEPTION 'Step origin immutable'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER ir_step_guard BEFORE UPDATE OR DELETE ON ir_steps FOR EACH ROW EXECUTE FUNCTION ir_step_guard();
SQL);
}
});
}
}
@@ -0,0 +1,10 @@
<?php
// Explicit candidate entry point. Not referenced by the normal SGF provider/routes.
foreach (['RequisitionPort', 'RequisitionFailure', 'InternalRequisitions'] as $class) {
require_once dirname(__DIR__, 3).'/packages/purchasing/src/'.$class.'.php';
}
foreach (['Schema', 'Port', 'Controller'] as $class) {
require_once __DIR__.'/'.$class.'.php';
}
config(['purchasing-internal.candidate_enabled' => true]);
@@ -0,0 +1,18 @@
<?php
use App\Modules\Identity\Http\RequireCompanyContext;
use App\Modules\Identity\Http\RequireValidSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Tempo\Purchasing\SgfCandidate\Controller;
// Required only by the isolated candidate harness; never by normal destination routes.
Route::middleware(['web', 'auth', RequireValidSession::class, RequireCompanyContext::class])->group(function () {
Route::get('/purchasing/internal-requisitions', [Controller::class, 'page']);
Route::get('/purchasing/internal-requisitions/screen.mjs', [Controller::class, 'script']);
Route::get('/api/internal-requisitions', [Controller::class, 'index']);
Route::get('/api/internal-requisitions/{id}', [Controller::class, 'show'])->whereNumber('id');
Route::post('/api/internal-requisitions/policy', [Controller::class, 'configure']);
Route::post('/api/internal-requisitions/save', [Controller::class, 'command'])->defaults('action', 'save');
Route::post('/api/internal-requisitions/{id}/{action}', fn (Request $r, int $id, string $action) => (new Controller)->command($r, $action, $id))->whereNumber('id')->whereIn('action', ['save', 'submit', 'decide']);
});
@@ -0,0 +1,429 @@
export function actionsFor(record, actor, permissions) {
if (Array.isArray(record.allowed_actions)) return record.allowed_actions;
const own = record.requester_id === actor;
if (record.status === 'draft')
return own && permissions.prepare ? ['edit', 'submit'] : [];
if (
record.status !== 'pending' ||
own ||
record.steps.some((step) => step.approver_id === actor)
)
return [];
const next = record.steps.find((step) => step.decision === 'pending');
return next && permissions[next.role] ? ['approved', 'rejected'] : [];
}
export function commandEnvelope(key, version, data = {}) {
return {
...data,
operation_key: key,
...(version == null ? {} : { version }),
};
}
export function limitMinor(value) {
if (!/^\d+(\.\d{1,2})?$/.test(value))
throw new Error('Indique o limite em MZN com até duas casas decimais.');
const [major, fraction = ''] = value.split('.');
const minor = BigInt(major) * 100n + BigInt(fraction.padEnd(2, '0'));
if (minor > 100000000000000n)
throw new Error('Limite superior ao montante suportado.');
return Number(minor);
}
export function limitMajor(minor) {
const value = BigInt(minor);
return `${value / 100n}.${String(value % 100n).padStart(2, '0')}`;
}
if (typeof document !== 'undefined') {
const contract = JSON.parse(
document.querySelector('#ir-contract').textContent,
);
const $ = (id) => document.getElementById(id);
const el = (tag, text, parent) => {
const node = document.createElement(tag);
if (text != null) node.textContent = text;
if (parent) parent.append(node);
return node;
};
let state,
selected,
draft,
dirty = false,
busy = false;
const keys = new Map();
const money = (minor) =>
new Intl.NumberFormat('pt-MZ', {
style: 'currency',
currency: 'MZN',
}).format(minor / 100);
function notice(text) {
$('notice').textContent = text;
$('notice').className = text ? 'notice' : '';
}
async function api(path, method = 'GET', data) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': contract.csrf,
'X-SGF-Context': contract.token,
'X-Purchasing-Context': contract.context,
},
...(data ? { body: JSON.stringify(data) } : {}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
Object.values(result.errors || {})
.flat()
.join(' ') ||
result.message ||
'Operação não concluída. O contexto e a chave de repetição foram mantidos.',
);
return result;
}
async function perform(work) {
if (busy) return;
busy = true;
notice('A processar…');
document.querySelectorAll('button').forEach((b) => (b.disabled = true));
try {
await work();
} catch (error) {
notice(error.message);
} finally {
busy = false;
document
.querySelectorAll('button')
.forEach((b) => (b.disabled = false));
}
}
async function command(action, record, data = {}) {
const slot = `${action}:${record?.id ?? 'new'}`;
if (!keys.has(slot)) keys.set(slot, crypto.randomUUID());
const result = await api(
`/api/internal-requisitions/${record ? `${record.id}/${action}` : 'save'}`,
'POST',
commandEnvelope(keys.get(slot), record?.version, data),
);
keys.delete(slot);
dirty = false;
selected = result;
if (action === 'save') draft = result;
try {
await load();
await detail(result.id);
notice(
result.replayed
? 'Resposta recuperada sem repetir efeitos.'
: 'Operação registada com origem e versão.',
);
} catch {
notice(
'Operação confirmada. Não foi possível actualizar a consulta; use Actualizar. A origem gravada foi mantida.',
);
}
return result;
}
function line(data = {}) {
const node = el('div', null, $('lines'));
node.className = 'line';
for (const [name, label, value] of [
['description', 'Descrição', ''],
['category', 'Categoria', ''],
['quantity', 'Quantidade', '1.000'],
['unit', 'Unidade', 'UN'],
['unit_price', 'Preço unitário MZN', ''],
['tax_rate', 'IVA %', ''],
]) {
const labelNode = el('label', label, node),
input = el('input', null, labelNode);
input.name = name;
input.value = data[name] ?? value;
input.required = true;
if (['quantity', 'unit_price', 'tax_rate'].includes(name))
input.inputMode = 'decimal';
}
const label = el('label', 'Tipo', node),
type = el('select', null, label);
type.name = 'purchase_type';
for (const [value, text] of [
['consumable', 'Consumível'],
['service', 'Serviço'],
['other', 'Outro'],
]) {
const option = el('option', text, type);
option.value = value;
}
type.value = data.purchase_type || 'consumable';
const remove = el('button', 'Remover', node);
remove.type = 'button';
remove.onclick = () => {
node.remove();
dirty = true;
};
}
function edit(record) {
if (dirty && !confirm('Descartar as alterações por guardar?')) return;
draft = record;
$('draft-panel').hidden = false;
$('draft-title').textContent = record
? `Editar ${record.number}`
: 'Nova requisição interna';
$('lines').replaceChildren();
const form = $('draft');
for (const name of [
'cost_center_id',
'priority',
'needed_by',
'justification',
])
form.elements[name].value =
record?.data[name] ?? (name === 'priority' ? 'normal' : '');
(record?.data.items ?? [{}]).forEach(line);
dirty = false;
}
async function detail(id) {
if (dirty && !confirm('Descartar as alterações por guardar?')) return;
const result = await api(`/api/internal-requisitions/${id}`);
selected = result.record;
$('detail').hidden = false;
$('detail-title').textContent =
`${selected.number} · ${selected.status} · Versão ${selected.version}`;
$('detail-data').replaceChildren();
el('p', selected.data.justification, $('detail-data'));
el(
'p',
`Total: ${money(selected.total_minor)} · Origem: ${selected.origin_key}`,
$('detail-data'),
);
if (selected.status !== 'draft')
el(
'p',
selected.reservation
? 'Reserva orçamental registada.'
: 'Sem reserva: política aplicada não a exigia e não havia dotação configurada.',
$('detail-data'),
);
$('steps').replaceChildren();
selected.steps.forEach((step) =>
el(
'p',
`${step.position}. ${step.role} · ${step.decision}${step.approver_id ? ` · Identidade ${step.approver_id}` : ''}`,
$('steps'),
),
);
$('history').replaceChildren();
result.history.forEach((event) =>
el(
'p',
`Versão ${event.version} · ${event.action} · ${event.created_at}`,
$('history'),
),
);
$('actions').replaceChildren();
for (const action of actionsFor(
selected,
contract.actor_id,
state.permissions,
)) {
const button = el(
'button',
{
edit: 'Editar',
submit: 'Submeter',
approved: 'Aprovar etapa',
rejected: 'Rejeitar',
}[action],
$('actions'),
);
button.onclick = () =>
action === 'edit'
? edit(selected)
: perform(async () => {
const comment =
action === 'rejected'
? prompt('Fundamente a rejeição')
: null;
if (action === 'rejected' && !comment) return;
await command(
action === 'approved' || action === 'rejected'
? 'decide'
: action,
selected,
action === 'submit'
? {}
: { decision: action, comment },
);
});
}
}
function mapping(parent, data, idField) {
const row = el('div', null, $(parent));
row.className = 'line';
const label = el(
'label',
idField === 'id' ? 'Centro de custo' : 'Pessoa',
row,
),
select = el('select', null, label);
select.name = idField;
select.required = true;
const empty = el('option', 'Seleccione…', select);
empty.value = '';
const sources =
state.configurationSources?.[
idField === 'id' ? 'centres' : 'members'
] ?? [];
for (const source of sources) {
const option = el('option', source.code ?? source.name, select);
option.value = source.id;
}
select.value = data?.[idField] ?? '';
const departmentLabel = el('label', 'Departamento', row),
input = el('input', null, departmentLabel);
input.name = 'department_ref';
input.value = data?.department_ref ?? '';
input.required = true;
const b = el('button', 'Retirar associação', row);
b.type = 'button';
b.onclick = () => {
row.remove();
dirty = true;
};
}
async function load() {
state = await api('/api/internal-requisitions');
$('new').hidden = !state.permissions.prepare;
$('policy-panel').hidden = !state.permissions.configure;
const select = $('draft').elements.cost_center_id;
select.replaceChildren();
(state.centres || []).forEach((c) => {
const o = el('option', `${c.label} · ${c.department_ref}`, select);
o.value = c.id;
});
$('records').replaceChildren();
if (!state.records.length)
el('p', 'Sem pedidos autorizados neste contexto.', $('records'));
state.records.forEach((r) => {
const b = el(
'button',
`${r.number} · ${r.status} · ${money(r.total_minor)}`,
$('records'),
);
b.onclick = () => perform(() => detail(r.id));
});
if (state.permissions.configure) {
const p = state.policy,
form = $('policy');
for (const k of [
'enabled',
'budget_required',
'approval_includes_tax',
])
form.elements[k].checked = p?.[k] ?? false;
for (const k of ['first_limit_minor', 'second_limit_minor'])
form.elements[k].value = p ? limitMajor(p[k]) : '';
$('policy-centres').replaceChildren();
$('policy-members').replaceChildren();
(p?.centres || []).forEach((c) =>
mapping('policy-centres', c, 'id'),
);
(p?.members || []).forEach((m) =>
mapping('policy-members', m, 'user_id'),
);
}
}
$('new').onclick = () => edit(null);
$('reload').onclick = () => perform(load);
$('add-line').onclick = () => {
line();
dirty = true;
};
$('draft').oninput = () => (dirty = true);
$('cancel-draft').onclick = () => {
if (!dirty || confirm('Descartar as alterações?')) {
$('draft-panel').hidden = true;
dirty = false;
}
};
$('draft').onsubmit = (event) => {
event.preventDefault();
void perform(async () => {
const form = event.target,
data = {};
for (const k of [
'cost_center_id',
'priority',
'needed_by',
'justification',
])
data[k] =
k === 'cost_center_id'
? Number(form.elements[k].value)
: form.elements[k].value;
data.items = [...$('lines').children].map((row) =>
Object.fromEntries(
[...row.querySelectorAll('input,select')].map((i) => [
i.name,
i.value,
]),
),
);
await command('save', draft, data);
$('draft-panel').hidden = true;
});
};
$('add-centre').onclick = () => {
mapping('policy-centres', null, 'id');
dirty = true;
};
$('add-member').onclick = () => {
mapping('policy-members', null, 'user_id');
dirty = true;
};
$('policy').oninput = () => (dirty = true);
$('policy').onsubmit = (event) => {
event.preventDefault();
void perform(async () => {
const f = event.target,
data = {
version: state.policy?.revision ?? 0,
reason: f.elements.reason.value,
};
for (const k of [
'enabled',
'budget_required',
'approval_includes_tax',
])
data[k] = f.elements[k].checked;
for (const k of ['first_limit_minor', 'second_limit_minor'])
data[k] = limitMinor(f.elements[k].value);
for (const [key, parent, id] of [
['centres', 'policy-centres', 'id'],
['members', 'policy-members', 'user_id'],
])
data[key] = [...$(parent).children].map((row) =>
Object.fromEntries(
[...row.querySelectorAll('input,select')].map((i) => [
i.name,
i.name === id ? Number(i.value) : i.value,
]),
),
);
await api('/api/internal-requisitions/policy', 'POST', data);
dirty = false;
await load();
notice(
'Parametrização versionada e registada. Permissões não foram alteradas.',
);
});
};
window.addEventListener('beforeunload', (event) => {
if (dirty) {
event.preventDefault();
event.returnValue = '';
}
});
void perform(load);
}
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Requisições internas · SGF</title>
<style>body{font:16px system-ui;margin:0;background:#f4f7f6;color:#15251e}header{background:#14251e;color:white;padding:24px}main{max-width:1120px;margin:auto;padding:24px}section{background:white;padding:24px;margin:20px 0;border:1px solid #d5dfda;border-radius:10px}label{display:block;margin:12px 0}input,select,textarea,button{font:inherit;padding:9px;border:1px solid #80988b;border-radius:5px;box-sizing:border-box}input,textarea,select{max-width:100%}textarea{width:100%;min-height:70px}button{background:#116748;color:white;cursor:pointer;margin:5px}button:disabled{opacity:.5;cursor:wait}table{width:100%;border-collapse:collapse}td,th{padding:9px;text-align:left;border-bottom:1px solid #ddd}.line{display:flex;gap:8px;flex-wrap:wrap;border-bottom:1px solid #ddd;padding:10px 0}.line input{width:135px}.line input:first-child{width:250px}.notice{padding:12px;background:#fff3cf}.hidden,[hidden]{display:none!important}a{color:inherit}pre{white-space:pre-wrap;font:inherit}fieldset{margin:12px 0}small{display:block}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #dc8c00;outline-offset:2px}@media(max-width:600px){main,section{padding:12px}table{font-size:13px}}</style></head>
<body><header><strong>SGF · Compras internas</strong><h1>Requisições internas</h1><p><?= htmlspecialchars($contract['company'].' · '.$contract['unit'], ENT_QUOTES, 'UTF-8')?></p><small>Candidata isolada de integração · sem activação na instalação de destino</small></header>
<main><p id="notice" role="status" aria-live="polite"></p><section><h2>Pedidos autorizados</h2><button id="reload">Actualizar</button><button id="new" hidden>Nova requisição</button><div id="records"></div></section>
<section id="draft-panel" hidden><h2 id="draft-title">Rascunho</h2><form id="draft"><label>Centro de custo <select name="cost_center_id" required></select></label><label>Prioridade <select name="priority"><option value="normal">Normal</option><option value="high">Alta</option><option value="urgent">Urgente</option></select></label><label>Data pretendida <input name="needed_by" type="date" required></label><label>Justificação <textarea name="justification" required minlength="10" maxlength="5000"></textarea></label><h3>Artigos ou serviços</h3><p>Os valores e o IVA são conferidos pelo servidor. Esta candidata cobre consumíveis, serviços e outros; equipamentos/peças exigem os respectivos percursos técnicos.</p><div id="lines"></div><button type="button" id="add-line">Adicionar linha</button><button type="submit">Guardar rascunho</button><button type="button" id="cancel-draft">Cancelar</button></form></section>
<section id="detail" hidden><h2 id="detail-title"></h2><div id="detail-data"></div><div id="actions"></div><h3>Etapas de aprovação</h3><div id="steps"></div><h3>Histórico por versão</h3><div id="history"></div></section>
<section id="policy-panel" hidden><h2>Parametrização deste percurso</h2><p>Não atribui permissões. Alterações aplicam-se a novas submissões; processos submetidos conservam a política utilizada.</p><form id="policy"><label><input name="enabled" type="checkbox"> Opção Compras internas activada para este percurso</label><label><input name="budget_required" type="checkbox"> Exigir dotação para submeter</label><label>Primeiro limite em MZN <input name="first_limit_minor" inputmode="decimal" required></label><label>Segundo limite em MZN <input name="second_limit_minor" inputmode="decimal" required></label><label><input name="approval_includes_tax" type="checkbox"> Aprovação inclui IVA</label><h3>Centros e departamentos autorizados</h3><p>Seleccione os centros e as pessoas dos cadastros autorizados da unidade.</p><div id="policy-centres"></div><button id="add-centre" type="button">Associar centro</button><h3>Pessoas e departamentos</h3><div id="policy-members"></div><button id="add-member" type="button">Associar pessoa</button><label>Motivo <textarea name="reason" required minlength="10"></textarea></label><button type="submit">Guardar parametrização</button></form></section></main>
<script type="application/json" id="ir-contract"><?= json_encode($contract, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR)?></script>
<script type="module" src="/purchasing/internal-requisitions/screen.mjs"></script></body></html>