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
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Tempo\Purchasing\Delivery;
/** Builds a review overlay; never writes to the source host. */
final class AccessOverlay
{
public const LABELS = [
'purchasing_requisitions.view' => 'Consultar requisições', 'purchasing_requisitions.prepare' => 'Preparar e submeter requisições',
'purchasing_requisitions.configure' => 'Configurar Compras internas', 'purchasing_requisitions.approve.department_head' => 'Aprovar como responsável de departamento',
'purchasing_requisitions.approve.procurement' => 'Aprovar como responsável de Compras', 'purchasing_requisitions.approve.finance' => 'Aprovar como responsável financeiro',
'purchasing_budget.create' => 'Preparar dotações', 'purchasing_budget.approve' => 'Aprovar dotações', 'purchasing_budget.record' => 'Registar e libertar reservas orçamentais',
'purchasing_budget.consume' => 'Registar consumo orçamental', 'purchasing_budget.view' => 'Consultar orçamento',
'purchasing_budget.reinforcement.propose' => 'Propor reforços orçamentais', 'purchasing_budget.reinforcement.review' => 'Rever reforços orçamentais',
];
public const RIGHTS = [
'purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure',
'purchasing_requisitions.approve.department_head', 'purchasing_requisitions.approve.procurement', 'purchasing_requisitions.approve.finance',
'purchasing_budget.create', 'purchasing_budget.approve', 'purchasing_budget.record', 'purchasing_budget.consume', 'purchasing_budget.view',
'purchasing_budget.reinforcement.propose', 'purchasing_budget.reinforcement.review',
];
public static function prepare(string $host): array
{
$paths = ['app/Modules/Identity/Services/Permissions.php', 'app/Modules/Identity/Services/IdentityWorkflow.php'];
$result = [];
foreach ($paths as $path) {
$source = file_get_contents($host.'/'.$path);
$before = hash('sha256', $source);
if (str_ends_with($path, '/Permissions.php')) {
$source = self::replace($source, "public const UNIT = ['unit.view', 'unit.edit'];", 'public const UNIT = '.var_export(['unit.view', 'unit.edit', ...self::RIGHTS], true).';');
} else {
// Expanding a catalogue must never expand creation-time grants.
$source = self::replace($source, 'foreach (Permissions::UNIT as $permission)', "foreach (['unit.view', 'unit.edit'] as \$permission)");
$source = self::replace($source,
"if (\$unit && \$data['permission'] === 'unit.edit')",
"if (\$unit && \$data['permission'] !== 'unit.view')");
$source = self::replace($source,
"if (\$this->permissions->has(\$target->id, \$tenant, \$company, 'unit.edit', \$unit)) { \$this->permissions->require(\$actor, \$tenant, \$company, 'unit.edit', \$unit); }",
"foreach ((clone \$query)->where('unit_id', \$unit)->whereIn('permission', Permissions::UNIT)->get() as \$existing) { \$this->permissions->require(\$actor, \$tenant, \$company, \$existing->permission, \$unit); }");
}
$result[$path] = ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source];
}
return $result;
}
private static function replace(string $source, string $old, string $new): string
{
if (substr_count($source, $old) !== 1) {
throw new \RuntimeException('Host divergiu: reconciliar a alteração de identidade antes de preparar a entrega.');
}
return str_replace($old, $new, $source);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Tempo\Purchasing\Delivery;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** Explicit initial delivery to nominated administrators, never automatic profile inheritance. */
final class InitialAuthority
{
public function options(Request $request): array
{
$context = app(CompanyContext::class);
$scope = $context->identifiers() + ['unit_id' => $context->unitId()];
$actor = $request->user()->id;
abort_unless($scope['unit_id'] !== null && $context->userId() === $actor, 403);
abort_unless(in_array(implode(':', $scope), config('purchasing-access.initialisation_scopes', []), true), 403);
$permissions = app(Permissions::class);
$context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'access.manage');
$members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')
->where('m.tenant_id', $scope['tenant_id'])->where('m.company_id', $scope['company_id'])->get(['u.id', 'u.name'])->filter(function ($member) use ($permissions, $scope) {
return $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'company.view')
&& $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'access.manage')
&& $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']);
})->values()->all();
return ['scope' => $scope, 'administrators' => $members, 'rights' => AccessOverlay::RIGHTS, 'labels' => AccessOverlay::LABELS,
'initialised' => DB::table('identity_audit')->where($scope)->where('action', 'purchasing.access_initialised')->exists()];
}
public function initialise(Request $request): array
{
$data = $request->validate([
'operation_key' => 'required|uuid', 'reason' => 'required|string|min:10|max:500',
'administrators' => 'required|array|min:1|max:20', 'administrators.*' => 'array:user_id,rights',
'administrators.*.user_id' => 'required|integer|distinct|min:1',
'administrators.*.rights' => 'required|array|min:1', 'administrators.*.rights.*' => 'required|string|distinct',
]);
abort_unless(array_diff(array_keys($request->all()), array_keys($data)) === [], 422);
$context = app(CompanyContext::class);
$scope = $context->identifiers() + ['unit_id' => $context->unitId()];
$actor = $request->user()->id;
abort_unless($scope['unit_id'] !== null && $context->userId() === $actor, 403);
// Installed configuration explicitly permits initialisation for this exact scope.
$scopeKey = implode(':', $scope);
abort_unless(in_array($scopeKey, config('purchasing-access.initialisation_scopes', []), true), 403);
foreach ($data['administrators'] as &$admin) {
abort_unless(array_diff($admin['rights'], AccessOverlay::RIGHTS) === [], 422);
sort($admin['rights']);
}
unset($admin);
usort($data['administrators'], fn ($a, $b) => $a['user_id'] <=> $b['user_id']);
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($request, $scope, $actor, $context, $data, $hash) {
DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail();
$context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$permissions = app(Permissions::class);
$permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'access.manage');
if ($request->hasSession()) {
$token = (string) $request->session()->get('company_context.token', '');
abort_unless($token !== '' && hash_equals($token, (string) $request->header('X-SGF-Context')), 409);
}
$old = DB::table('identity_audit')->where($scope)->where('action', 'purchasing.access_initialised')->first();
if ($old) {
$details = json_decode($old->details, true, 32, JSON_THROW_ON_ERROR);
abort_unless($old->actor_id === $actor && ($details['event_id'] ?? null) === $hash, 409, 'Autoridade inicial já entregue; use a gestão normal de acessos.');
return ['initialised' => true, 'replayed' => true];
}
$grants = [];
foreach ($data['administrators'] as $admin) {
foreach (['company.view', 'access.manage'] as $right) {
$permissions->require($admin['user_id'], $scope['tenant_id'], $scope['company_id'], $right);
}
$permissions->require($admin['user_id'], $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']);
abort_unless(DB::table('company_memberships')->where(['user_id' => $admin['user_id'], 'tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id']])->exists(), 403);
foreach ($admin['rights'] as $right) {
abort_unless(in_array($right, Permissions::UNIT, true), 503, 'Catálogo operacional não preparado.');
DB::table('permission_grants')->insertOrIgnore($scope + ['user_id' => $admin['user_id'], 'permission' => $right, 'created_at' => now(), 'updated_at' => now()]);
$grants[] = ['user_id' => $admin['user_id'], 'permission' => $right];
}
}
Audit::record('purchasing.access_initialised', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['reason' => $data['reason'], 'event_id' => $hash, 'grants' => $grants]);
return ['initialised' => true, 'replayed' => false];
});
}
}
+23
View File
@@ -0,0 +1,23 @@
<?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\Delivery\InitialAuthority;
// Deployment composition must explicitly register this file. Never loaded by the installed host.
require_once __DIR__.'/AccessOverlay.php';
require_once __DIR__.'/InitialAuthority.php';
Route::middleware(['web', 'auth', RequireValidSession::class, RequireCompanyContext::class])->group(function () {
Route::get('/purchasing/internal-requisitions/access-initialisation', function (Request $request) {
$options = (new InitialAuthority)->options($request);
ob_start();
require __DIR__.'/screen.php';
return response(ob_get_clean())->header('Cache-Control', 'private, no-store');
});
Route::post('/api/internal-requisitions/access-initialisation', function (Request $request) {
return response()->json((new InitialAuthority)->initialise($request))->header('Cache-Control', 'private, no-store');
});
});
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Configuração inicial de acessos a Compras internas</title></head>
<body>
<main><h1>Configuração inicial de acessos a Compras internas</h1>
<p>Empresa <?= (int) $options['scope']['company_id'] ?> · Unidade <?= (int) $options['scope']['unit_id'] ?></p>
<p>Escolha explicitamente o administrador e os direitos iniciais. As restantes atribuições e revogações usam a administração de acessos existente.</p>
<?php if ($options['initialised']) { ?>
<p>A autoridade inicial já foi entregue. Utilize a administração de acessos para alterações.</p>
<?php } else { ?>
<form id="initial-access" method="post" action="/api/internal-requisitions/access-initialisation">
<input type="hidden" name="_token" value="<?= htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
<label>Administrador nominativo <select name="administrator" required>
<option value="">Seleccionar</option>
<?php foreach ($options['administrators'] as $member) { ?>
<option value="<?= (int) $member->id ?>"><?= htmlspecialchars($member->name, ENT_QUOTES, 'UTF-8') ?> · <?= (int) $member->id ?></option>
<?php } ?>
</select></label>
<fieldset><legend>Direitos iniciais escolhidos para esta unidade</legend>
<?php foreach ($options['rights'] as $right) { ?>
<label><input type="checkbox" name="rights" value="<?= htmlspecialchars($right, ENT_QUOTES, 'UTF-8') ?>"> <?= htmlspecialchars($options['labels'][$right], ENT_QUOTES, 'UTF-8') ?></label><br>
<?php } ?>
</fieldset>
<label>Fundamento <textarea name="reason" minlength="10" maxlength="500" required></textarea></label>
<button type="submit">Registar atribuição inicial</button>
</form>
<p id="result" role="status" aria-live="polite"></p>
<script>
const form = document.getElementById('initial-access');
const contextToken = <?= json_encode($request->session()->get('company_context.token'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
let pending = null;
form.addEventListener('submit', async (event) => {
event.preventDefault();
const fields = new FormData(form);
const payload = {reason: fields.get('reason'), administrators: [{user_id: Number(fields.get('administrator')), rights: fields.getAll('rights')}]};
const content = JSON.stringify(payload);
if (pending?.content !== content) pending = {content, operation_key: crypto.randomUUID()};
const button = form.querySelector('button');
button.disabled = true;
try {
const response = await fetch(form.action, {method: 'POST', headers: {'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': fields.get('_token'), 'X-SGF-Context': contextToken}, body: JSON.stringify({...payload, operation_key: pending.operation_key})});
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Não foi possível registar.');
form.hidden = true;
document.getElementById('result').textContent = 'Atribuição inicial registada. Utilize a administração de acessos para alterações.';
} catch (error) {
document.getElementById('result').textContent = error.message;
} finally { button.disabled = false; }
});
</script>
<?php } ?>
</main></body></html>
@@ -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>
@@ -0,0 +1,36 @@
<?php
namespace Tempo\Purchasing\Delivery;
final class BootOverlay
{
public static function prepare(string $host): array
{
$result = [];
foreach (['app/Providers/AppServiceProvider.php', 'app/Shared/Context/ModuleNavigation.php'] as $path) {
$source = file_get_contents($host.'/'.$path);
$hash = hash('sha256', $source);
$source = str_replace("\r\n", "\n", $source);
if (str_ends_with($path, 'AppServiceProvider.php')) {
$old = '$this->app->scoped(CompanyContext::class';
$new = "if (is_file(base_path('packages/internal-requisitions/bootstrap.php'))) { require_once base_path('packages/internal-requisitions/bootstrap.php'); }\n ".$old;
$source = self::replace($source, $old, $new);
$source = self::replace($source, "public function boot(): void\n {", "public function boot(): void\n {\n if (is_file(base_path('packages/internal-requisitions/bootstrap.php'))) { \$this->loadRoutesFrom(base_path('packages/internal-requisitions/integrations/sgf/operational/routes.php')); }");
} else {
$source = self::replace($source, "'purchasing'=>['purchasing_budget.', 'supplier_payables.']", "'purchasing'=>['purchasing_budget.', 'purchasing_requisitions.', 'supplier_payables.']");
}
$result[$path] = ['before_sha256' => $hash, 'after_sha256' => hash('sha256', $source), 'contents' => $source];
}
return $result;
}
private static function replace(string $source, string $old, string $new): string
{
if (substr_count($source, $old) !== 1) {
throw new \RuntimeException('Arranque/navegação do host divergiu; reconciliar antes de preparar.');
}
return str_replace($old, $new, $source);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Tempo\Purchasing\Delivery;
final class BudgetOverlay
{
public static function prepare(string $host): array
{
$path = 'app/Modules/Purchasing/BudgetAdapter.php';
$source = file_get_contents($host.'/'.$path);
$before = hash('sha256', $source);
$anchor = "public static function guard(): void\n {";
$source = str_replace("\r\n", "\n", $source);
if (substr_count($source, $anchor) !== 1) {
throw new \RuntimeException('Adaptador orçamental divergente.');
}
$source = str_replace($anchor, $anchor."\n if (config('purchasing-operational.enabled') === true) {\n \\Tempo\\Purchasing\\Delivery\\Installation::assertBudgetReady(DB::connection());\n return;\n }", $source);
return [$path => ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source]];
}
}
@@ -0,0 +1,97 @@
<?php
namespace Tempo\Purchasing\Delivery;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Blueprint;
/** Host schema preserving the existing SGF common-budget contract; no business motor or fixtures. */
final class BudgetSchema
{
public static function create(ConnectionInterface $db): void
{
$schema = $db->getSchemaBuilder();
$scope = function (Blueprint $t): void {
$t->unsignedBigInteger('tenant_id');
$t->unsignedBigInteger('company_id');
$t->unsignedBigInteger('unit_id');
$t->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete();
};
$schema->create('cb_centres', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->string('code');
$t->unique(['tenant_id', 'company_id', 'unit_id', 'code']);
});
$schema->create('cb_origins', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('cost_center_id')->constrained('cb_centres');
$t->string('required_kind');
$t->string('reference')->unique();
});
$schema->create('cb_budgets', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('cost_center_id')->constrained('cb_centres');
$t->integer('year');
$t->string('kind')->default('operating');
$t->bigInteger('amount_minor');
$t->boolean('includes_tax');
$t->text('reason');
$t->foreignId('created_by')->constrained('users');
$t->foreignId('approved_by')->nullable()->constrained('users');
$t->timestamp('approved_at')->nullable();
$t->timestamps();
$t->unique(['tenant_id', 'company_id', 'unit_id', 'cost_center_id', 'year']);
});
$schema->create('cb_budget_entries', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('budget_id')->constrained('cb_budgets');
$t->foreignId('requisition_id')->constrained('cb_origins');
$t->foreignId('created_by')->constrained('users');
$t->string('kind');
$t->bigInteger('reserved_minor');
$t->bigInteger('committed_minor');
$t->timestamp('created_at');
$t->unique(['requisition_id', 'kind']);
});
$schema->create('cb_budget_consumptions', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('budget_id')->constrained('cb_budgets');
// Only a synthetic source identifier, not a payable or invoice implementation.
$t->unsignedBigInteger('invoice_id')->unique();
$t->foreignId('created_by')->constrained('users');
$t->bigInteger('amount_minor');
$t->timestamp('created_at');
});
$schema->create('cb_budget_reinforcements', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('budget_id')->constrained('cb_budgets');
$t->foreignId('created_by')->constrained('users');
$t->foreignId('reviewed_by')->nullable()->constrained('users');
$t->string('reference');
$t->string('payload_hash', 64);
$t->bigInteger('amount_minor');
$t->text('reason');
$t->string('status')->default('pending');
$t->text('review_reason')->nullable();
$t->timestamp('reviewed_at')->nullable();
$t->timestamps();
$t->unique(['tenant_id', 'company_id', 'unit_id', 'reference']);
});
$schema->create('cb_audit', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('actor_id')->constrained('users');
$t->string('action');
$t->unsignedBigInteger('origin_id')->nullable();
$t->jsonb('payload');
$t->timestamp('created_at');
});
}
}
@@ -0,0 +1,23 @@
<?php
namespace Tempo\Purchasing\Delivery;
use Illuminate\Support\Facades\Route;
final class Composition
{
public static function routes(string $bundle): void
{
$previous = [];
foreach (Route::getRoutes() as $route) {
$previous[spl_object_id($route)] = true;
}
require $bundle.'/integrations/sgf/internal-requisitions/routes.php';
require $bundle.'/integrations/sgf/access/routes.php';
foreach (Route::getRoutes() as $route) {
if (! isset($previous[spl_object_id($route)])) {
$route->middleware(RuntimeContext::class);
}
}
}
}
@@ -0,0 +1,120 @@
<?php
namespace Tempo\Purchasing\Delivery;
use App\Shared\Context\CompanyContext;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Blueprint;
use Tempo\Purchasing\SgfCandidate\Schema as RequisitionSchema;
/** Schema preparation is deliberately independent of route/code activation and grants. */
final class Installation
{
public const TABLES = ['cb_centres', 'cb_origins', 'cb_budgets', 'cb_budget_entries', 'cb_budget_consumptions', 'cb_budget_reinforcements', 'cb_audit'];
public static function target(ConnectionInterface $db): string
{
if ($db->getDriverName() !== 'pgsql' || $db->selectOne('select current_database() as name')->name !== $db->getDatabaseName()) {
throw new \RuntimeException('Destino PostgreSQL não conferido.');
}
$server = $db->selectOne('select inet_server_addr()::text as address, inet_server_port() as port');
return hash('sha256', json_encode([$db->getDriverName(), $db->getConfig('host'), (string) $db->getConfig('port'), $db->getDatabaseName(), $server->address, $server->port], JSON_THROW_ON_ERROR));
}
public static function migrationHash(): string
{
return hash('sha256', file_get_contents(__DIR__.'/BudgetSchema.php').file_get_contents(__DIR__.'/budget-contract.json').file_get_contents(dirname(__DIR__).'/internal-requisitions/Schema.php'));
}
public static function signature(ConnectionInterface $db, array $tables): string
{
sort($tables);
$bindings = implode(',', array_fill(0, count($tables), '?'));
return hash('sha256', json_encode([
$db->select("select table_name,column_name,data_type,character_maximum_length,is_nullable,ordinal_position from information_schema.columns where table_schema='public' and table_name in ($bindings) order by table_name,ordinal_position", $tables),
$db->select("select c.relname,k.conname,pg_get_constraintdef(k.oid) as definition from pg_constraint k join pg_class c on c.oid=k.conrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relname in ($bindings) order by c.relname,k.conname", $tables),
$db->select("select c.relname,t.tgname,pg_get_triggerdef(t.oid) as definition from pg_trigger t join pg_class c on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relname in ($bindings) and not t.tgisinternal order by c.relname,t.tgname", $tables),
], JSON_THROW_ON_ERROR));
}
public static function tables(): array
{
return [...self::TABLES, 'permission_grants', 'ir_requests', 'ir_steps', 'ir_events', 'ir_commands', 'ir_policies', 'ir_policy_versions'];
}
public static function migrate(ConnectionInterface $db, string $expectedTarget, string $backupHash, string $operator): array
{
if (! hash_equals(self::target($db), $expectedTarget) || ! preg_match('/^[a-f0-9]{64}$/D', $backupHash) || trim($operator) === '') {
throw new \RuntimeException('Destino, backup ou operador não conferidos.');
}
return $db->transaction(function () use ($db, $backupHash, $operator) {
$db->select("select pg_advisory_xact_lock(hashtext('tempo.internal-requisitions.install.v1'))");
$schema = $db->getSchemaBuilder();
if ($schema->hasTable('sgc_installations')) {
$old = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first();
if (! $old || $old->revision !== 1 || ! hash_equals($old->migration_sha256, self::migrationHash()) || ! hash_equals($old->schema_signature, self::signature($db, self::tables()))) {
throw new \RuntimeException('Instalação/schema divergente; reconciliar sem sobrescrever.');
}
return ['prepared' => true, 'replayed' => true, 'activated' => false];
}
foreach (['users', 'tenants', 'companies', 'units', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) {
if (! $schema->hasTable($table)) {
throw new \RuntimeException('Fundação de identidade ausente: '.$table);
}
}
$budgetTables = array_filter(self::TABLES, fn ($table) => $schema->hasTable($table));
if ($budgetTables !== [] && count($budgetTables) !== count(self::TABLES)) {
throw new \RuntimeException('Schema orçamental parcial; não completar às cegas.');
}
if ($budgetTables === []) {
BudgetSchema::create($db);
}
$contract = json_decode(file_get_contents(__DIR__.'/budget-contract.json'), true, 16, JSON_THROW_ON_ERROR);
if (! hash_equals($contract['schema_signature'], self::signature($db, self::TABLES))) {
throw new \RuntimeException('Schema orçamental incompatível com o contrato aceite.');
}
$constraint = $db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
if (! $constraint || ! preg_match('/^CHECK\s*\((.*)\)$/s', $constraint->definition, $parts)) {
throw new \RuntimeException('Catálogo de âmbito desconhecido.');
}
$budgetRights = array_values(array_filter(AccessOverlay::RIGHTS, fn ($right) => str_starts_with($right, 'purchasing_budget.')));
if (array_filter($budgetRights, fn ($right) => ! str_contains($constraint->definition, "'".$right."'")) !== []) {
$literals = implode(',', array_map(fn ($right) => $db->getPdo()->quote($right).'::text', $budgetRights));
$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 ($literals)))");
}
RequisitionSchema::install($db);
$schema->create('sgc_installations', function (Blueprint $table) {
$table->string('module')->primary();
$table->integer('revision');
$table->string('schema_signature', 64);
$table->string('migration_sha256', 64);
$table->string('backup_sha256', 64);
$table->string('operator');
$table->string('database_operator');
$table->timestampTz('prepared_at');
});
$db->table('sgc_installations')->insert(['module' => 'internal-requisitions', 'revision' => 1, 'schema_signature' => self::signature($db, self::tables()), 'migration_sha256' => self::migrationHash(), 'backup_sha256' => $backupHash, 'operator' => $operator, 'database_operator' => $db->selectOne('select current_user as name')->name, 'prepared_at' => now()]);
$db->unprepared("CREATE FUNCTION sgc_installations_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve installation history'; END; \$\$; CREATE TRIGGER sgc_installations_immutable BEFORE UPDATE OR DELETE ON sgc_installations FOR EACH ROW EXECUTE FUNCTION sgc_installations_immutable();");
return ['prepared' => true, 'replayed' => false, 'activated' => false];
});
}
public static function assertBudgetReady(ConnectionInterface $db): void
{
abort_unless(config('purchasing-operational.enabled') === true, 403);
abort_unless(hash_equals((string) config('purchasing-operational.target', ''), self::target($db)), 403, 'Destino operacional divergente.');
$context = app(CompanyContext::class);
$scope = $context->identifiers() + ['unit_id' => $context->unitId()];
abort_unless($scope['unit_id'] !== null && in_array(implode(':', $scope), config('purchasing-operational.scopes', []), true), 403, 'Opção interna não configurada para esta unidade.');
abort_unless($db->getSchemaBuilder()->hasTable('sgc_installations'), 503, 'Schema operacional não preparado.');
$row = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first();
abort_unless($row && $row->revision === 1 && hash_equals($row->migration_sha256, self::migrationHash()) && hash_equals($row->schema_signature, self::signature($db, self::tables())), 503, 'Schema operacional não preparado.');
}
}
@@ -0,0 +1,34 @@
<?php
namespace Tempo\Purchasing\Delivery;
use App\Shared\Context\CompanyContext;
use App\Shared\Context\ModuleNavigation;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class RuntimeContext
{
public function handle(Request $request, Closure $next): mixed
{
abort_unless(config('purchasing-operational.enabled') === true, 404);
$context = app(CompanyContext::class);
Installation::assertBudgetReady(DB::connection());
$scope = $context->identifiers();
$policy = config('modules.companies.'.$scope['tenant_id'].':'.$scope['company_id'], []);
$contracted = $policy['contracted'] ?? config('modules.contracted', []);
abort_unless(in_array('purchasing', $contracted, true) && in_array('purchasing', $policy['enabled'] ?? $contracted, true), 403);
$initialisation = $request->is('purchasing/internal-requisitions/access-initialisation', 'api/internal-requisitions/access-initialisation');
if (! $initialisation) {
abort_unless($request->session()->get('company_context.module_id') === 'purchasing', 409, 'Seleccione o módulo Compras.');
abort_unless(in_array('purchasing', array_column(app(ModuleNavigation::class)->available($context), 'id'), true), 403);
}
if ($request->is('api/*')) {
$token = (string) $request->session()->get('company_context.token', '');
abort_unless($token !== '' && hash_equals($token, (string) $request->header('X-SGF-Context')), 409);
}
return $next($request);
}
}
@@ -0,0 +1,7 @@
<?php
// Explicit deployment composition; never required by the installed SGF provider.
require_once __DIR__.'/BudgetSchema.php';
require_once dirname(__DIR__).'/access/AccessOverlay.php';
require_once __DIR__.'/Installation.php';
require_once dirname(__DIR__).'/internal-requisitions/bootstrap.php';
@@ -0,0 +1,4 @@
{
"schema_signature": "44f37aa636b110921737b7dcabf9344c9332cff1d1e7e7f047d6128208939d69",
"origin": "Existing accepted SGF common-budget host schema; no data or credentials"
}
+6
View File
@@ -0,0 +1,6 @@
<?php
// Copied as <local bundle>/bootstrap.php, without a source-project dependency.
require_once __DIR__.'/integrations/sgf/operational/bootstrap.php';
require_once __DIR__.'/integrations/sgf/operational/RuntimeContext.php';
require_once __DIR__.'/integrations/sgf/operational/Composition.php';
+5
View File
@@ -0,0 +1,5 @@
<?php
use Tempo\Purchasing\Delivery\Composition;
Composition::routes(dirname(__DIR__, 3));