commit14
This commit is contained in:
@@ -6,6 +6,8 @@ namespace Tempo\Purchasing\Delivery;
|
||||
final class AccessOverlay
|
||||
{
|
||||
public const LABELS = [
|
||||
'purchasing_sourcing.prepare'=>'Preparar consultas e cotações', 'purchasing_sourcing.evaluate'=>'Avaliar cotações',
|
||||
'purchasing_sourcing.propose'=>'Propor adjudicação', 'purchasing_sourcing.confirm'=>'Confirmar adjudicação independente',
|
||||
'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',
|
||||
@@ -15,6 +17,7 @@ final class AccessOverlay
|
||||
];
|
||||
|
||||
public const RIGHTS = [
|
||||
'purchasing_sourcing.prepare','purchasing_sourcing.evaluate','purchasing_sourcing.propose','purchasing_sourcing.confirm',
|
||||
'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',
|
||||
|
||||
@@ -89,7 +89,7 @@ final class Controller
|
||||
$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 ['record' => $this->project($port, $record), 'history' => $port->query('events')->where('request_id', $id)->orderBy('version')->get(), 'canSourcing'=>config('purchasing-sourcing.enabled')===true&&$record['status']==='approved'];
|
||||
});
|
||||
|
||||
return response()->json($result)->header('Cache-Control', 'private, no-store');
|
||||
@@ -159,7 +159,7 @@ final class Controller
|
||||
$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')];
|
||||
'company' => DB::table('companies')->where('id', $scope['company_id'])->value('name'), 'unit' => DB::table('units')->where('id', $scope['unit_id'])->value('name'), 'canBudgetSetup' => app(Permissions::class)->has($port->actorId(), $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.view', $scope['unit_id'])];
|
||||
ob_start();
|
||||
require __DIR__.'/screen.php';
|
||||
$html = ob_get_clean();
|
||||
|
||||
@@ -121,7 +121,9 @@ final class Port implements RequisitionPort
|
||||
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());
|
||||
// Scoped view is sufficient for reading; it never grants mutation rights.
|
||||
// load()/query() and atomic() retain tenant, company, unit and option checks.
|
||||
$visible = $action === 'view' || $all || $record['requester_id'] === $this->actor->id || ($this->has('approve.department_head') && $record['department_ref'] === $this->department());
|
||||
abort_unless($visible, 404);
|
||||
}
|
||||
if (in_array($action, ['save', 'submit'], true)) {
|
||||
|
||||
@@ -191,6 +191,10 @@ if (typeof document !== 'undefined') {
|
||||
$('detail-title').textContent =
|
||||
`${selected.number} · ${selected.status} · Versão ${selected.version}`;
|
||||
$('detail-data').replaceChildren();
|
||||
if(result.canSourcing){
|
||||
const link=el('a','Consulta de fornecedores, cotações e adjudicação',$('detail-data'));
|
||||
link.href='/purchasing/sourcing/'+selected.id;
|
||||
}
|
||||
el('p', selected.data.justification, $('detail-data'));
|
||||
el(
|
||||
'p',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!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>
|
||||
<body><header><strong>SGF · Compras internas</strong><h1>Requisições internas</h1><p><?= htmlspecialchars($contract['company'].' · '.$contract['unit'], ENT_QUOTES, 'UTF-8')?></p><nav><a href="/">Navegação SGF</a><?php if ($contract['canBudgetSetup']) { ?> · <a href="/purchasing/internal-requisitions/budget-setup">Centros e dotações</a><?php } ?></nav></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>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Delivery;
|
||||
|
||||
/** Native administration consumes the server catalogue; no second authority list. */
|
||||
final class AdminUiOverlay
|
||||
{
|
||||
public static function prepare(string $host, array $files): array
|
||||
{
|
||||
$workflow = 'app/Modules/Identity/Services/IdentityWorkflow.php';
|
||||
$old = "'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]];";
|
||||
$contents = $files[$workflow]['contents'];
|
||||
if (substr_count($contents, $old) !== 1) {
|
||||
throw new \RuntimeException('Catálogo administrativo do host divergiu.');
|
||||
}
|
||||
$contents = str_replace($old, "'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT], 'catalogue_labels' => \\Tempo\\Purchasing\\Delivery\\AccessOverlay::LABELS];", $contents);
|
||||
$files[$workflow]['contents'] = $contents;
|
||||
$files[$workflow]['after_sha256'] = hash('sha256', $contents);
|
||||
$node = getenv('SGF_NODE_BINARY') ?: 'node';
|
||||
$process = proc_open([$node, dirname(__DIR__, 3).'/scripts/sgf-admin-ui-build.mjs', $host], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (! is_resource($process)) {
|
||||
throw new \RuntimeException('Não foi possível preparar a interface.');
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
if (proc_close($process) !== 0) {
|
||||
throw new \RuntimeException('Frontend candidato: '.$error);
|
||||
}
|
||||
$metadata = json_decode(file_get_contents(json_decode($output, true, 16, JSON_THROW_ON_ERROR)['metadata']), true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach ($metadata['overlays'] as $file => $source) {
|
||||
$body = file_get_contents($source);
|
||||
$files[$file] = ['before_sha256' => hash_file('sha256', $host.'/'.$file), 'after_sha256' => hash('sha256', $body), 'contents' => $body];
|
||||
}
|
||||
$assets = [];
|
||||
foreach ($metadata['assets'] as $file => $source) {
|
||||
$assets[$file] = file_get_contents($source);
|
||||
}
|
||||
|
||||
return ['files' => $files, 'assets' => $assets];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,19 @@ final class BudgetOverlay
|
||||
}
|
||||
$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]];
|
||||
$result = [$path => ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source]];
|
||||
$path = 'app/Modules/Purchasing/BudgetWorkspace.php';
|
||||
$source = file_get_contents($host.'/'.$path);
|
||||
$before = hash('sha256', $source);
|
||||
$anchor = 'public function command(User $user, string $action, array $input, string $key): array'."\n {";
|
||||
$source = str_replace("\r\n", "\n", $source);
|
||||
if (substr_count($source, $anchor) !== 1) {
|
||||
throw new \RuntimeException('Workspace orçamental divergente.');
|
||||
}
|
||||
$source = str_replace($anchor, $anchor."\n if (config('purchasing-operational.enabled') === true) { abort_unless(in_array(\$action, ['create', 'approve'], true), 403, 'Movimentos orçamentais operacionais pertencem ao processo de origem.'); }", $source);
|
||||
$source = str_replace("'synthetic_only' => true", "'synthetic_only' => config('purchasing-operational.enabled') !== true", $source);
|
||||
$result[$path] = ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source];
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Delivery;
|
||||
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\SgfCandidate\Port;
|
||||
|
||||
final class BudgetSetup
|
||||
{
|
||||
public function centre(Request $request)
|
||||
{
|
||||
$data = $request->validate(['operation_key' => 'required|uuid', 'code' => ['required', 'string', 'max:100', 'regex:/^[\pL\pN][\pL\pN ._\/-]*$/uD'], 'reason' => 'required|string|min:10|max:500']);
|
||||
abort_unless(array_diff(array_keys($request->all()), array_keys($data)) === [], 422);
|
||||
$port = new Port($request->user());
|
||||
$result = $port->atomic(function () use ($port, $data) {
|
||||
$port->authorize('configure', null);
|
||||
$scope = $port->scope();
|
||||
app(Permissions::class)->require($port->actorId(), $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.create', $scope['unit_id']);
|
||||
$hash = hash('sha256', json_encode([$data['code'], $data['reason']], JSON_THROW_ON_ERROR));
|
||||
$receipts = DB::table('cb_ui_commands')->where($scope)->where('actor_id', $port->actorId())->where('command_key', $data['operation_key']);
|
||||
if ($old = $receipts->first()) {
|
||||
abort_unless($old->action === 'centre.create' && hash_equals($old->request_hash, $hash), 409);
|
||||
|
||||
return json_decode($old->result, true, 16, JSON_THROW_ON_ERROR) + ['replayed' => true];
|
||||
}
|
||||
abort_if(DB::table('cb_centres')->where($scope)->where('code', $data['code'])->exists(), 409, 'Centro já cadastrado; seleccione a fonte existente.');
|
||||
$id = DB::table('cb_centres')->insertGetId($scope + ['code' => $data['code']]);
|
||||
$result = ['centre_id' => $id, 'code' => $data['code']];
|
||||
DB::table('cb_audit')->insert($scope + ['actor_id' => $port->actorId(), 'action' => 'centre.created', 'origin_id' => null, 'payload' => json_encode($result + ['reason' => $data['reason']], JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
DB::table('cb_ui_commands')->insert($scope + ['actor_id' => $port->actorId(), 'command_key' => $data['operation_key'], 'action' => 'centre.create', 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
|
||||
return $result;
|
||||
});
|
||||
|
||||
return response()->json($result)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function page(Request $request)
|
||||
{
|
||||
Installation::assertBudgetReady(DB::connection());
|
||||
$ctx = app(CompanyContext::class);
|
||||
$scope = $ctx->identifiers() + ['unit_id' => $ctx->unitId()];
|
||||
$p = app(Permissions::class);
|
||||
$actor = $request->user()->id;
|
||||
$p->require($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.view', $scope['unit_id']);
|
||||
$contract = ['csrf' => csrf_token(), 'token' => $request->session()->get('company_context.token'), 'scope' => $scope, 'actor_id' => $actor,
|
||||
'canCentre' => $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.configure', $scope['unit_id']) && $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.create', $scope['unit_id']),
|
||||
'canPrepare' => $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.create', $scope['unit_id']),
|
||||
'canApprove' => $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.approve', $scope['unit_id'])];
|
||||
ob_start();
|
||||
require __DIR__.'/budget-setup.php';
|
||||
|
||||
return response(ob_get_clean())->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Delivery;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
/** Empty dependencies of the existing workspace, no demo seeds. */
|
||||
final class BudgetUiSchema
|
||||
{
|
||||
public const TABLES = ['cb_ui_commands', 'cb_consumption_sources'];
|
||||
|
||||
public static function create(ConnectionInterface $db): void
|
||||
{
|
||||
$schema = $db->getSchemaBuilder();
|
||||
foreach (self::TABLES as $table) {
|
||||
if ($schema->hasTable($table)) {
|
||||
throw new \RuntimeException('Dependência de interface existente exige reconciliação explícita.');
|
||||
}
|
||||
}
|
||||
$scope = function (Blueprint $t) {
|
||||
foreach (['tenant_id', 'company_id', 'unit_id'] as $name) {
|
||||
$t->unsignedBigInteger($name);
|
||||
}
|
||||
$t->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete();
|
||||
};
|
||||
$schema->create('cb_ui_commands', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->foreign(['actor_id', 'tenant_id', 'company_id'])->references(['user_id', 'tenant_id', 'company_id'])->on('company_memberships')->restrictOnDelete();
|
||||
$t->uuid('command_key');
|
||||
$t->string('action');
|
||||
$t->string('request_hash', 64);
|
||||
$t->jsonb('result');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'command_key']);
|
||||
});
|
||||
$schema->create('cb_consumption_sources', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('origin_id')->constrained('cb_origins');
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
$t->string('reference', 100);
|
||||
$t->bigInteger('total_minor');
|
||||
$t->bigInteger('subtotal_minor');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['tenant_id', 'company_id', 'unit_id', 'reference']);
|
||||
});
|
||||
$db->unprepared("CREATE FUNCTION cb_ui_commands_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve budget command receipts'; END; \$\$; CREATE TRIGGER cb_ui_commands_immutable BEFORE UPDATE OR DELETE ON cb_ui_commands FOR EACH ROW EXECUTE FUNCTION cb_ui_commands_immutable();");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Delivery;
|
||||
|
||||
/** Offline, journalled file deployment. Never migrates data, enables configuration or restarts services. */
|
||||
final class CodeDeployment
|
||||
{
|
||||
private string $host;
|
||||
|
||||
public function __construct(string $host)
|
||||
{
|
||||
if (trim($host) === '') {
|
||||
throw new \RuntimeException('Host explícito obrigatório.');
|
||||
}
|
||||
$this->host = str_replace('\\', '/', realpath($host) ?: throw new \RuntimeException('Host inexistente.'));
|
||||
}
|
||||
|
||||
private function path(string $relative): string
|
||||
{
|
||||
if ($relative === '' || str_contains($relative, '..') || ! preg_match('~^[A-Za-z0-9_./-]+$~D', $relative)) {
|
||||
throw new \RuntimeException('Caminho de implantação inválido.');
|
||||
}
|
||||
$path = $this->host.'/'.$relative;
|
||||
$part = $this->host;
|
||||
foreach (explode('/', $relative) as $segment) {
|
||||
$part .= '/'.$segment;
|
||||
if (is_link($part)) {
|
||||
throw new \RuntimeException('Links recusados na implantação.');
|
||||
}
|
||||
}
|
||||
$parent = $path;
|
||||
while (! file_exists($parent)) {
|
||||
$parent = dirname($parent);
|
||||
}
|
||||
$resolved = str_replace('\\', '/', realpath($parent) ?: '');
|
||||
if ($resolved !== $this->host && ! str_starts_with(strtolower($resolved), strtolower($this->host).'/')) {
|
||||
throw new \RuntimeException('Caminho fora do host.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private static function write(string $path, string $contents): void
|
||||
{
|
||||
if (! is_dir(dirname($path)) && ! mkdir(dirname($path), 0700, true)) {
|
||||
throw new \RuntimeException('Falha no directório de implantação.');
|
||||
}
|
||||
$temp = $path.'.'.bin2hex(random_bytes(6)).'.next';
|
||||
$stream = fopen($temp, 'xb');
|
||||
if (! $stream) {
|
||||
throw new \RuntimeException('Falha na preparação do ficheiro.');
|
||||
}
|
||||
chmod($temp, 0600);
|
||||
try {
|
||||
$written = 0;
|
||||
while ($written < strlen($contents)) {
|
||||
$n = fwrite($stream, substr($contents, $written));
|
||||
if ($n === false || $n === 0) {
|
||||
throw new \RuntimeException('Escrita incompleta.');
|
||||
}
|
||||
$written += $n;
|
||||
}
|
||||
fflush($stream);
|
||||
if (function_exists('fsync')) {
|
||||
fsync($stream);
|
||||
}
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
if (! rename($temp, $path)) {
|
||||
throw new \RuntimeException('Falha na troca do ficheiro.');
|
||||
}
|
||||
}
|
||||
|
||||
private function manifest(string $bundle, string $expected): array
|
||||
{
|
||||
if (trim($bundle) === '') {
|
||||
throw new \RuntimeException('Bundle explícito obrigatório.');
|
||||
}
|
||||
$bundle = realpath($bundle) ?: throw new \RuntimeException('Bundle inexistente.');
|
||||
$text = file_get_contents($bundle.'/manifest.json');
|
||||
if (! hash_equals($expected, hash('sha256', $text))) {
|
||||
throw new \RuntimeException('Manifesto divergente.');
|
||||
}
|
||||
$manifest = json_decode($text, true, 32, JSON_THROW_ON_ERROR);
|
||||
if (($manifest['format'] ?? '') !== 'tempo.operational-review.v1') {
|
||||
throw new \RuntimeException('Formato de entrega desconhecido.');
|
||||
}
|
||||
$expectedFiles = array_fill_keys([...array_keys($manifest['support_files']), 'manifest.json'], true);
|
||||
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($bundle, \FilesystemIterator::SKIP_DOTS)) as $entry) {
|
||||
$relative = str_replace('\\', '/', substr($entry->getPathname(), strlen($bundle) + 1));
|
||||
if ($entry->isLink() || ($entry->isFile() && ! isset($expectedFiles[$relative]))) {
|
||||
throw new \RuntimeException('Conteúdo extra ou link no bundle.');
|
||||
}
|
||||
}
|
||||
foreach ($manifest['support_files'] as $file => $hash) {
|
||||
$resolved = str_replace('\\', '/', realpath($bundle.'/'.$file) ?: '');
|
||||
if (str_contains($file, '..') || ! preg_match('~^(bootstrap\.php|(app|packages|integrations|scripts)/[A-Za-z0-9_./-]+|resources/js/(app|PurchasingHome)\.jsx|public/build/manifest\.json)$~D', $file)
|
||||
|| ! str_starts_with(strtolower($resolved), strtolower(str_replace('\\', '/', $bundle)).'/') || is_link($bundle.'/'.$file) || ! is_file($bundle.'/'.$file) || ! hash_equals($hash, hash_file('sha256', $bundle.'/'.$file))) {
|
||||
throw new \RuntimeException('Integridade do bundle divergente.');
|
||||
}
|
||||
}
|
||||
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
||||
if ((! str_starts_with($file, 'app/') && ! in_array($file, ['resources/js/app.jsx', 'resources/js/PurchasingHome.jsx', 'public/build/manifest.json'], true)) || ($manifest['support_files'][$file] ?? null) !== $hashes['after_sha256']) {
|
||||
throw new \RuntimeException('Sobreposição inválida.');
|
||||
}
|
||||
$this->path($file);
|
||||
}
|
||||
|
||||
return [$bundle, $manifest];
|
||||
}
|
||||
|
||||
public function plan(string $bundle, string $manifestHash): array
|
||||
{
|
||||
$this->cachePaths();
|
||||
[, $manifest] = $this->manifest($bundle, $manifestHash);
|
||||
$pointer = $this->path('packages/purchasing/current.json');
|
||||
$pointerHash = hash_file('sha256', $pointer);
|
||||
$previous = $this->current();
|
||||
$oldManifest = null;
|
||||
if (is_dir($this->path('packages/internal-requisitions'))) {
|
||||
if ($previous === null) {
|
||||
throw new \RuntimeException('Bundle sem proveniência de implantação; reconciliar explicitamente.');
|
||||
}
|
||||
$journal = json_decode(file_get_contents($this->path('storage/app/private/module-deployments/'.$previous['deployment_id'].'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($journal['state'] !== 'applied') {
|
||||
throw new \RuntimeException('Implantação incompleta exige recuperação.');
|
||||
}
|
||||
$this->verifyApplied($journal, $previous['deployment_id']);
|
||||
[, $oldManifest] = $this->manifest($this->path('packages/internal-requisitions'), $previous['manifest_sha256']);
|
||||
if (array_keys($oldManifest['host_overlays']) !== array_keys($manifest['host_overlays'])) {
|
||||
throw new \RuntimeException('Mudança de composição do host exige plano próprio.');
|
||||
}
|
||||
foreach (['integrations/sgf/operational/BudgetSchema.php', 'integrations/sgf/operational/BudgetUiSchema.php', 'integrations/sgf/operational/SqlPrivileges.php', 'integrations/sgf/operational/Installation.php', 'integrations/sgf/operational/budget-contract.json', 'integrations/sgf/internal-requisitions/Schema.php', 'integrations/sgf/access/AccessOverlay.php'] as $file) {
|
||||
if (($oldManifest['support_files'][$file] ?? null) !== ($manifest['support_files'][$file] ?? null)) {
|
||||
$receiptPath=$this->path('storage/app/private/module-deployments/schema-transition-'.$manifestHash.'.json');
|
||||
$receipt=is_file($receiptPath)?json_decode(file_get_contents($receiptPath),true,32,JSON_THROW_ON_ERROR):[];
|
||||
if(($receipt['from_manifest_sha256']??null)!==$previous['manifest_sha256']||($receipt['to_manifest_sha256']??null)!==$manifestHash||($receipt['host']??null)!==$this->host||($receipt['changes'][$file]['before']??null)!==($oldManifest['support_files'][$file]??null)||($receipt['changes'][$file]['after']??null)!==($manifest['support_files'][$file]??null)||($receipt['schema_verified']??false)!==true){
|
||||
throw new \RuntimeException('Transição de schema exige migração própria e recibo exacto do destino.');
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($previous !== null) {
|
||||
throw new \RuntimeException('Registo activo sem bundle; recuperar a implantação anterior.');
|
||||
}
|
||||
$before = [];
|
||||
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
||||
$hash = hash_file('sha256', $this->path($file));
|
||||
$expected = $oldManifest === null ? $hashes['before_sha256'] : $oldManifest['host_overlays'][$file]['after_sha256'];
|
||||
if ($oldManifest !== null && $hashes['before_sha256'] !== $oldManifest['host_overlays'][$file]['before_sha256']) {
|
||||
throw new \RuntimeException('Baseline do host mudou entre versões.');
|
||||
}
|
||||
if ($hash !== $expected) {
|
||||
throw new \RuntimeException('Host alterado; reconciliar antes de implantar.');
|
||||
}
|
||||
$before[$file] = $hash;
|
||||
}
|
||||
$target = hash('sha256', json_encode([$this->host, $pointerHash, $before, $previous], JSON_THROW_ON_ERROR));
|
||||
|
||||
return ['target_sha256' => $target, 'pointer_sha256' => $pointerHash, 'host_files' => $before, 'manifest_sha256' => $manifestHash, 'previous' => $previous, 'deployment_id' => $this->attempt($manifestHash), 'writes' => false];
|
||||
}
|
||||
|
||||
private function current(): ?array
|
||||
{
|
||||
$path = $this->path('storage/app/private/module-deployments/current.json');
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$value = json_decode(file_get_contents($path), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (! preg_match('/^[a-f0-9]{24}$/D', $value['deployment_id'] ?? '') || ! preg_match('/^[a-f0-9]{64}$/D', $value['manifest_sha256'] ?? '')) {
|
||||
throw new \RuntimeException('Registo activo inválido.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function attempt(string $hash): string
|
||||
{
|
||||
for ($i = 0; $i < 1000; $i++) {
|
||||
$id = substr($i === 0 ? $hash : hash('sha256', $hash.':'.$i), 0, 24);
|
||||
$file = $this->path('storage/app/private/module-deployments/'.$id.'/journal.json');
|
||||
if (! is_file($file) || json_decode(file_get_contents($file), true, 32, JSON_THROW_ON_ERROR)['state'] !== 'rolled-back') {
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Reconciliar histórico de tentativas.');
|
||||
}
|
||||
|
||||
private function offline(bool $stopped): void
|
||||
{
|
||||
$this->cachePaths();
|
||||
if (! $stopped || ! is_file($this->path('storage/framework/down'))) {
|
||||
throw new \RuntimeException('Exige manutenção activa e serviços de escrita parados.');
|
||||
}
|
||||
if (! is_array(json_decode(file_get_contents($this->path('storage/framework/down')), true))) {
|
||||
throw new \RuntimeException('Registo de manutenção inválido.');
|
||||
}
|
||||
}
|
||||
|
||||
private function cachePaths(): void
|
||||
{
|
||||
$expected = ['APP_CONFIG_CACHE' => 'bootstrap/cache/config.php', 'APP_EVENTS_CACHE' => 'bootstrap/cache/events.php', 'APP_PACKAGES_CACHE' => 'bootstrap/cache/packages.php', 'APP_SERVICES_CACHE' => 'bootstrap/cache/services.php', 'APP_ROUTES_CACHE' => 'bootstrap/cache/routes-v7.php'];
|
||||
$values = [];
|
||||
$env = $this->path('.env');
|
||||
if (is_file($env)) {
|
||||
foreach (file($env, FILE_IGNORE_NEW_LINES) as $line) {
|
||||
if (preg_match('/^\s*(APP_(?:CONFIG|EVENTS|PACKAGES|SERVICES|ROUTES)_CACHE)\s*=\s*(.*?)\s*$/D', $line, $match)) {
|
||||
$values[$match[1]] = trim($match[2], "\"'");
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($expected as $key => $relative) {
|
||||
$value = getenv($key);
|
||||
if ($value === false) {
|
||||
$value = $values[$key] ?? '';
|
||||
}
|
||||
if ($value !== '' && str_replace('\\', '/', $value) !== $relative && str_replace('\\', '/', $value) !== $this->host.'/'.$relative) {
|
||||
throw new \RuntimeException('Cache Laravel personalizado exige plano de implantação próprio.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function locked(callable $work): mixed
|
||||
{
|
||||
$lock = $this->path('storage/app/private/module-deployments/deployment.lock');
|
||||
if (! is_dir(dirname($lock))) {
|
||||
mkdir(dirname($lock), 0700, true);
|
||||
}
|
||||
$stream = fopen($lock, 'c+');
|
||||
if (! $stream || ! flock($stream, LOCK_EX | LOCK_NB)) {
|
||||
throw new \RuntimeException('Outra implantação em curso.');
|
||||
}
|
||||
try {
|
||||
return $work();
|
||||
} finally {
|
||||
flock($stream, LOCK_UN);
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
private function journal(string $id, array $data): void
|
||||
{
|
||||
self::write($this->path('storage/app/private/module-deployments/'.$id.'/journal.json'), json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function caches(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (glob($this->path('bootstrap/cache').'/*.php') ?: [] as $file) {
|
||||
if (! in_array(basename($file), ['config.php', 'events.php', 'packages.php', 'services.php'], true) && ! preg_match('/^routes(-v[0-9]+)?\.php$/D', basename($file))) {
|
||||
continue;
|
||||
}
|
||||
$relative = 'bootstrap/cache/'.basename($file);
|
||||
$out[$relative] = hash_file('sha256', $this->path($relative));
|
||||
}
|
||||
ksort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function rebuildCaches(string $id, string $operator, bool $stopped, ?callable $checkpoint = null): array
|
||||
{
|
||||
if (! preg_match('/^[a-f0-9]{24}$/D', $id)) {
|
||||
throw new \RuntimeException('Implantação inválida.');
|
||||
}
|
||||
|
||||
return $this->locked(function () use ($id, $operator, $stopped, $checkpoint) {
|
||||
$this->offline($stopped);
|
||||
if (trim($operator) === '') {
|
||||
throw new \RuntimeException('Operador obrigatório.');
|
||||
}
|
||||
$prefix = 'storage/app/private/module-deployments/'.$id;
|
||||
$data = json_decode(file_get_contents($this->path($prefix.'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($data['state'] !== 'applied') {
|
||||
throw new \RuntimeException('Código não está aplicado.');
|
||||
}
|
||||
$this->verifyApplied($data, $id);
|
||||
$run = bin2hex(random_bytes(6));
|
||||
$beforeCaches = $this->caches();
|
||||
foreach ($beforeCaches as $file => $hash) {
|
||||
self::write($this->path($prefix.'/cache-runs/'.$run.'/before/'.$file), file_get_contents($this->path($file)));
|
||||
}
|
||||
$data['cache_state'] = 'building';
|
||||
$this->journal($id, $data);
|
||||
if ($this->caches() !== $beforeCaches) {
|
||||
throw new \RuntimeException('Cache mudou durante preparação da compilação.');
|
||||
}
|
||||
// Clear before booting Artisan: an interrupted config cache must never prevent recovery.
|
||||
foreach ($beforeCaches as $file => $hash) {
|
||||
if (! unlink($this->path($file))) {
|
||||
throw new \RuntimeException('Falha ao invalidar cache antes da compilação.');
|
||||
}
|
||||
}
|
||||
$failure = null;
|
||||
$commands = [];
|
||||
try {
|
||||
foreach (['config:cache', 'route:cache', 'event:cache'] as $command) {
|
||||
$log = $this->path($prefix.'/cache-runs/'.$run.'/native-cache.log');
|
||||
if (! is_dir(dirname($log))) {
|
||||
mkdir(dirname($log), 0700, true);
|
||||
}
|
||||
$process = proc_open([PHP_BINARY, $this->path('artisan'), $command], [0 => ['pipe', 'r'], 1 => ['file', $log, 'a'], 2 => ['file', $log, 'a']], $pipes, $this->host);
|
||||
if (! is_resource($process)) {
|
||||
throw new \RuntimeException('Não foi possível iniciar a compilação nativa.');
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
$exit = proc_close($process);
|
||||
$commands[] = ['command' => $command, 'exit_code' => $exit];
|
||||
if ($exit !== 0) {
|
||||
throw new \RuntimeException('Falha na compilação nativa de cache; consultar log privado.');
|
||||
}
|
||||
$checkpoint && $checkpoint($command);
|
||||
}
|
||||
} catch (\Throwable $error) {
|
||||
$failure = $error;
|
||||
} finally {
|
||||
$data['built_cache_files'] = $this->caches();
|
||||
$data['cache_state'] = $failure ? 'failed' : 'built';
|
||||
$data['cache_runs'][] = ['id' => $run, 'operator' => $operator, 'at' => gmdate('c'), 'commands' => $commands, 'state' => $data['cache_state']];
|
||||
$this->journal($id, $data);
|
||||
}
|
||||
if ($failure) {
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
return ['deployment_id' => $id, 'cache_state' => 'built', 'restart_required' => true, 'maintenance_preserved' => true];
|
||||
});
|
||||
}
|
||||
|
||||
public function apply(string $bundle, string $manifestHash, string $target, string $operator, bool $stopped, ?callable $checkpoint = null): array
|
||||
{
|
||||
return $this->locked(function () use ($bundle, $manifestHash, $target, $operator, $stopped, $checkpoint) {
|
||||
$this->offline($stopped);
|
||||
if (trim($operator) === '') {
|
||||
throw new \RuntimeException('Operador nominativo obrigatório.');
|
||||
}
|
||||
[$bundle, $manifest] = $this->manifest($bundle, $manifestHash);
|
||||
$id = $this->attempt($manifestHash);
|
||||
$journalPath = $this->path('storage/app/private/module-deployments/'.$id.'/journal.json');
|
||||
if (is_file($journalPath)) {
|
||||
$old = json_decode(file_get_contents($journalPath), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($old['state'] !== 'applied') {
|
||||
throw new \RuntimeException('Implantação anterior exige recuperação explícita.');
|
||||
}
|
||||
$this->verifyApplied($old, $id);
|
||||
|
||||
return ['deployment_id' => $id, 'state' => 'applied', 'replayed' => true, 'restart_required' => true];
|
||||
}
|
||||
$plan = $this->plan($bundle, $manifestHash);
|
||||
if (! hash_equals($target, $plan['target_sha256'])) {
|
||||
throw new \RuntimeException('Destino de código divergente.');
|
||||
}
|
||||
$package = $this->path('packages/internal-requisitions');
|
||||
$prefix = 'storage/app/private/module-deployments/'.$id;
|
||||
$data = ['state' => 'prepared', 'operator' => $operator, 'created_at' => gmdate('c'), 'manifest_sha256' => $manifestHash,
|
||||
'host_identity' => hash('sha256', $this->host),
|
||||
'previous' => $plan['previous'],
|
||||
'pointer_sha256' => $plan['pointer_sha256'], 'files' => [], 'cache_files' => [], 'bundle_files' => $manifest['support_files']];
|
||||
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
||||
self::write($this->path($prefix.'/before/'.$file), file_get_contents($this->path($file)));
|
||||
$hashes['before_sha256'] = $plan['host_files'][$file];
|
||||
$data['files'][$file] = $hashes;
|
||||
}
|
||||
foreach (glob($this->path('bootstrap/cache').'/*.php') ?: [] as $cache) {
|
||||
if (! in_array(basename($cache), ['config.php', 'events.php', 'packages.php', 'services.php'], true) && ! preg_match('/^routes(-v[0-9]+)?\.php$/D', basename($cache))) {
|
||||
continue;
|
||||
}
|
||||
$relative = 'bootstrap/cache/'.basename($cache);
|
||||
$this->path($relative);
|
||||
self::write($this->path($prefix.'/before/'.$relative), file_get_contents($cache));
|
||||
$data['cache_files'][$relative] = hash_file('sha256', $cache);
|
||||
}
|
||||
foreach ($manifest['support_files'] as $file => $hash) {
|
||||
self::write($this->path($prefix.'/staged/'.$file), file_get_contents($bundle.'/'.$file));
|
||||
}
|
||||
self::write($this->path($prefix.'/staged/manifest.json'), file_get_contents($bundle.'/manifest.json'));
|
||||
$this->journal($id, $data);
|
||||
$checkpoint && $checkpoint('prepared');
|
||||
$data['state'] = 'applying';
|
||||
$this->journal($id, $data);
|
||||
foreach ($data['cache_files'] as $file => $hash) {
|
||||
if (! hash_equals($hash, hash_file('sha256', $this->path($file)))) {
|
||||
throw new \RuntimeException('Cache mudou durante implantação.');
|
||||
}
|
||||
if (! unlink($this->path($file))) {
|
||||
throw new \RuntimeException('Não foi possível invalidar cache.');
|
||||
}
|
||||
}
|
||||
$checkpoint && $checkpoint('caches-cleared');
|
||||
foreach ($data['files'] as $file => $hashes) {
|
||||
if (! hash_equals($hashes['before_sha256'], hash_file('sha256', $this->path($file)))) {
|
||||
throw new \RuntimeException('Ficheiro mudou durante implantação.');
|
||||
}
|
||||
self::write($this->path($file), file_get_contents($bundle.'/'.$file));
|
||||
$checkpoint && $checkpoint('file:'.$file);
|
||||
}
|
||||
// Both resolved paths are checked within the host before this directory move.
|
||||
if ($data['previous'] !== null) {
|
||||
if (! rename($package, $this->path($prefix.'/previous-bundle'))) {
|
||||
throw new \RuntimeException('Falha ao preservar bundle anterior.');
|
||||
}
|
||||
$checkpoint && $checkpoint('previous-bundle-preserved');
|
||||
}
|
||||
if (! rename($this->path($prefix.'/staged'), $package)) {
|
||||
throw new \RuntimeException('Falha na troca do bundle.');
|
||||
}
|
||||
$checkpoint && $checkpoint('bundle-switched');
|
||||
$this->verifyApplied($data, $id, false);
|
||||
self::write($this->path('storage/app/private/module-deployments/current.json'), json_encode(['deployment_id' => $id, 'manifest_sha256' => $manifestHash], JSON_THROW_ON_ERROR));
|
||||
$data['state'] = 'applied';
|
||||
$data['applied_at'] = gmdate('c');
|
||||
$this->journal($id, $data);
|
||||
|
||||
return ['deployment_id' => $id, 'state' => 'applied', 'restart_required' => true, 'maintenance_preserved' => true, 'configuration_changed' => false, 'activation_performed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
private function verifyApplied(array $data, string $id, bool $checkCurrent = true): void
|
||||
{
|
||||
if ($checkCurrent && $this->current() !== ['deployment_id' => $id, 'manifest_sha256' => $data['manifest_sha256']]) {
|
||||
throw new \RuntimeException('A implantação já não é a versão activa.');
|
||||
}
|
||||
if (! hash_equals($data['host_identity'], hash('sha256', $this->host))) {
|
||||
throw new \RuntimeException('Registo pertence a outro host.');
|
||||
}
|
||||
if (! hash_equals($data['pointer_sha256'], hash_file('sha256', $this->path('packages/purchasing/current.json')))) {
|
||||
throw new \RuntimeException('Ponteiro de Compras mudou.');
|
||||
}
|
||||
$this->manifest($this->path('packages/internal-requisitions'), $data['manifest_sha256']);
|
||||
foreach ($data['files'] as $file => $hashes) {
|
||||
if (! hash_equals($hashes['after_sha256'], hash_file('sha256', $this->path($file)))) {
|
||||
throw new \RuntimeException('Código aplicado alterado.');
|
||||
}
|
||||
}
|
||||
foreach ($data['bundle_files'] as $file => $hash) {
|
||||
if (! hash_equals($hash, hash_file('sha256', $this->path('packages/internal-requisitions/'.$file)))) {
|
||||
throw new \RuntimeException('Bundle aplicado alterado.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rollback(string $id, string $operator, bool $stopped): array
|
||||
{
|
||||
if (! preg_match('/^[a-f0-9]{24}$/D', $id)) {
|
||||
throw new \RuntimeException('Implantação inválida.');
|
||||
}
|
||||
|
||||
return $this->locked(function () use ($id, $operator, $stopped) {
|
||||
$this->offline($stopped);
|
||||
if (trim($operator) === '') {
|
||||
throw new \RuntimeException('Operador obrigatório.');
|
||||
}
|
||||
$prefix = 'storage/app/private/module-deployments/'.$id;
|
||||
$data = json_decode(file_get_contents($this->path($prefix.'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
|
||||
$active = $this->current();
|
||||
$own = ['deployment_id' => $id, 'manifest_sha256' => $data['manifest_sha256']];
|
||||
if ($active !== $own && $active !== ($data['previous'] ?? null)) {
|
||||
throw new \RuntimeException('Outra versão está activa; recusar reversão fora de ordem.');
|
||||
}
|
||||
if (! hash_equals($data['host_identity'], hash('sha256', $this->host))) {
|
||||
throw new \RuntimeException('Registo pertence a outro host.');
|
||||
}
|
||||
if (! hash_equals($data['pointer_sha256'], hash_file('sha256', $this->path('packages/purchasing/current.json')))) {
|
||||
throw new \RuntimeException('Ponteiro divergiu; não recuperar por arrasto.');
|
||||
}
|
||||
if ($data['state'] === 'rolled-back') {
|
||||
return ['deployment_id' => $id, 'state' => 'rolled-back', 'replayed' => true, 'restart_required' => true, 'maintenance_preserved' => true, 'data_schema_changed' => false];
|
||||
}
|
||||
$rebuilt = array_key_exists('built_cache_files', $data);
|
||||
if ($rebuilt && $this->caches() !== $data['built_cache_files']) {
|
||||
throw new \RuntimeException('Cache mudou após compilação registada.');
|
||||
}
|
||||
foreach ($data['files'] as $file => $hashes) {
|
||||
if (! in_array(hash_file('sha256', $this->path($file)), [$hashes['before_sha256'], $hashes['after_sha256']], true)) {
|
||||
throw new \RuntimeException('Código local alterado; recuperação recusada.');
|
||||
}
|
||||
if (! hash_equals($hashes['before_sha256'], hash_file('sha256', $this->path($prefix.'/before/'.$file)))) {
|
||||
throw new \RuntimeException('Backup de código divergente.');
|
||||
}
|
||||
}
|
||||
foreach ($data['cache_files'] as $file => $hash) {
|
||||
if (! hash_equals($hash, hash_file('sha256', $this->path($prefix.'/before/'.$file)))) {
|
||||
throw new \RuntimeException('Backup de cache divergente.');
|
||||
}
|
||||
if (! $rebuilt && is_file($this->path($file)) && ! hash_equals($hash, hash_file('sha256', $this->path($file)))) {
|
||||
throw new \RuntimeException('Cache recriado; reconciliar antes de recuperar.');
|
||||
}
|
||||
}
|
||||
if (! $rebuilt && array_diff(array_keys($this->caches()), array_keys($data['cache_files'])) !== []) {
|
||||
throw new \RuntimeException('Cache recriado; reconciliar antes de recuperar.');
|
||||
}
|
||||
if (is_dir($this->path($prefix.'/previous-bundle'))) {
|
||||
$this->manifest($this->path($prefix.'/previous-bundle'), $data['previous']['manifest_sha256']);
|
||||
}
|
||||
if (is_dir($this->path('packages/internal-requisitions'))) {
|
||||
$isPrevious = $active === ($data['previous'] ?? null) && $data['previous'] !== null && ! is_dir($this->path($prefix.'/previous-bundle'));
|
||||
if ($isPrevious) {
|
||||
$this->manifest($this->path('packages/internal-requisitions'), $data['previous']['manifest_sha256']);
|
||||
}
|
||||
if (! $isPrevious) {
|
||||
$this->manifest($this->path('packages/internal-requisitions'), $data['manifest_sha256']);
|
||||
foreach ($data['bundle_files'] as $file => $hash) {
|
||||
if (! hash_equals($hash, hash_file('sha256', $this->path('packages/internal-requisitions/'.$file)))) {
|
||||
throw new \RuntimeException('Bundle alterado; recuperação recusada.');
|
||||
}
|
||||
}
|
||||
if (! rename($this->path('packages/internal-requisitions'), $this->path($prefix.'/withdrawn'))) {
|
||||
throw new \RuntimeException('Falha ao retirar bundle.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_dir($this->path($prefix.'/previous-bundle'))) {
|
||||
$this->manifest($this->path($prefix.'/previous-bundle'), $data['previous']['manifest_sha256']);
|
||||
if (! rename($this->path($prefix.'/previous-bundle'), $this->path('packages/internal-requisitions'))) {
|
||||
throw new \RuntimeException('Falha ao repor bundle anterior.');
|
||||
}
|
||||
}
|
||||
$data['state'] = 'rolling-back';
|
||||
$this->journal($id, $data);
|
||||
foreach ($data['files'] as $file => $hashes) {
|
||||
self::write($this->path($file), file_get_contents($this->path($prefix.'/before/'.$file)));
|
||||
}
|
||||
if ($rebuilt) {
|
||||
foreach ($data['built_cache_files'] as $file => $hash) {
|
||||
self::write($this->path($prefix.'/rollback-cache/'.$file), file_get_contents($this->path($file)));
|
||||
unlink($this->path($file));
|
||||
}
|
||||
$data['cache_rollback'] = 'invalidated-rebuilt';
|
||||
} else {
|
||||
foreach ($data['cache_files'] as $file => $hash) {
|
||||
self::write($this->path($file), file_get_contents($this->path($prefix.'/before/'.$file)));
|
||||
}
|
||||
}
|
||||
$currentPath = $this->path('storage/app/private/module-deployments/current.json');
|
||||
if ($data['previous'] !== null) {
|
||||
self::write($currentPath, json_encode($data['previous'], JSON_THROW_ON_ERROR));
|
||||
} elseif (is_file($currentPath)) {
|
||||
unlink($currentPath);
|
||||
}
|
||||
$data['state'] = 'rolled-back';
|
||||
$data['rollback_operator'] = $operator;
|
||||
$data['rolled_back_at'] = gmdate('c');
|
||||
$this->journal($id, $data);
|
||||
|
||||
return ['deployment_id' => $id, 'state' => 'rolled-back', 'restart_required' => true, 'maintenance_preserved' => true, 'data_schema_changed' => false];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ final class Composition
|
||||
}
|
||||
require $bundle.'/integrations/sgf/internal-requisitions/routes.php';
|
||||
require $bundle.'/integrations/sgf/access/routes.php';
|
||||
require $bundle.'/integrations/sgf/operational/setup-routes.php';
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
if (! isset($previous[spl_object_id($route)])) {
|
||||
$route->middleware(RuntimeContext::class);
|
||||
|
||||
@@ -25,7 +25,7 @@ final class Installation
|
||||
|
||||
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'));
|
||||
return hash('sha256', file_get_contents(__DIR__.'/BudgetSchema.php').file_get_contents(__DIR__.'/BudgetUiSchema.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
|
||||
@@ -42,7 +42,7 @@ final class Installation
|
||||
|
||||
public static function tables(): array
|
||||
{
|
||||
return [...self::TABLES, 'permission_grants', 'ir_requests', 'ir_steps', 'ir_events', 'ir_commands', 'ir_policies', 'ir_policy_versions'];
|
||||
return [...self::TABLES, ...BudgetUiSchema::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
|
||||
@@ -56,7 +56,7 @@ final class Installation
|
||||
$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()))) {
|
||||
if (! $old || $old->revision !== 2 || ! 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.');
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ final class Installation
|
||||
$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);
|
||||
BudgetUiSchema::create($db);
|
||||
$schema->create('sgc_installations', function (Blueprint $table) {
|
||||
$table->string('module')->primary();
|
||||
$table->integer('revision');
|
||||
@@ -99,7 +100,7 @@ final class Installation
|
||||
$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->table('sgc_installations')->insert(['module' => 'internal-requisitions', 'revision' => 2, '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];
|
||||
@@ -115,6 +116,10 @@ final class Installation
|
||||
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.');
|
||||
$signatureMatches=$row && hash_equals($row->schema_signature, self::signature($db, self::tables()));
|
||||
if(!$signatureMatches && class_exists(\Tempo\Purchasing\SgfSourcing\Schema::class)){
|
||||
$signatureMatches=\Tempo\Purchasing\SgfSourcing\Schema::compatible($db);
|
||||
}
|
||||
abort_unless($row && $row->revision === 2 && hash_equals($row->migration_sha256, self::migrationHash()) && $signatureMatches, 503, 'Schema operacional não preparado.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Delivery;
|
||||
|
||||
/** Proposal only. Installation never executes these statements automatically. */
|
||||
final class SqlPrivileges
|
||||
{
|
||||
public const TABLES = [
|
||||
'cb_centres' => 'SELECT, INSERT', 'cb_origins' => 'SELECT, INSERT', 'cb_budgets' => 'SELECT, INSERT, UPDATE',
|
||||
'cb_budget_entries' => 'SELECT, INSERT', 'cb_audit' => 'SELECT, INSERT', 'cb_ui_commands' => 'SELECT, INSERT',
|
||||
'cb_budget_consumptions' => 'SELECT', 'cb_budget_reinforcements' => 'SELECT', 'cb_consumption_sources' => 'SELECT',
|
||||
'ir_requests' => 'SELECT, INSERT, UPDATE', 'ir_steps' => 'SELECT, INSERT, UPDATE', 'ir_policies' => 'SELECT, INSERT, UPDATE',
|
||||
'ir_events' => 'SELECT, INSERT', 'ir_commands' => 'SELECT, INSERT', 'ir_policy_versions' => 'SELECT, INSERT', 'sgc_installations' => 'SELECT',
|
||||
];
|
||||
|
||||
public static function plan(string $runtime): array
|
||||
{
|
||||
if (! preg_match('/^[a-z][a-z0-9_]{0,62}$/D', $runtime)) {
|
||||
throw new \RuntimeException('Role runtime explícita inválida.');
|
||||
}
|
||||
$sql = [];
|
||||
foreach (self::TABLES as $table => $rights) {
|
||||
// New objects can inherit wider ACLs from the destination owner's
|
||||
// defaults. Scope the correction to these new objects only; never
|
||||
// change existing tables or ALTER DEFAULT PRIVILEGES globally.
|
||||
$sql[] = 'REVOKE ALL PRIVILEGES ON TABLE public."'.$table.'" FROM "'.$runtime.'"';
|
||||
$sql[] = 'REVOKE ALL PRIVILEGES ON TABLE public."'.$table.'" FROM PUBLIC';
|
||||
$sql[] = 'GRANT '.$rights.' ON TABLE public."'.$table.'" TO "'.$runtime.'"';
|
||||
if (str_contains($rights, 'INSERT')) {
|
||||
$sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM "'.$runtime.'"';
|
||||
$sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM PUBLIC';
|
||||
$sql[] = 'GRANT USAGE ON SEQUENCE public."'.$table.'_id_seq" TO "'.$runtime.'"';
|
||||
} elseif ($table !== 'sgc_installations') {
|
||||
$sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM "'.$runtime.'"';
|
||||
$sql[] = 'REVOKE ALL PRIVILEGES ON SEQUENCE public."'.$table.'_id_seq" FROM PUBLIC';
|
||||
}
|
||||
}
|
||||
|
||||
return ['runtime_role' => $runtime, 'confirmation_required' => true, 'automatically_applied' => false, 'statements' => $sql, 'excluded' => ['CREATE', 'ownership', 'DELETE', 'UPDATE on append-only history', 'all-tables/default-privileges grants']];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
// Explicit deployment composition; never required by the installed SGF provider.
|
||||
require_once __DIR__.'/BudgetSchema.php';
|
||||
require_once __DIR__.'/BudgetUiSchema.php';
|
||||
require_once __DIR__.'/SqlPrivileges.php';
|
||||
require_once dirname(__DIR__).'/access/AccessOverlay.php';
|
||||
require_once __DIR__.'/Installation.php';
|
||||
require_once dirname(__DIR__).'/internal-requisitions/bootstrap.php';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Preparação orçamental · Compras internas</title></head><body><main>
|
||||
<h1>Preparação orçamental · Compras internas</h1><p>Empresa <?= (int) $scope['company_id'] ?> · Unidade <?= (int) $scope['unit_id'] ?></p>
|
||||
<nav><a href="/purchasing/internal-requisitions">Requisições e parametrização</a> · <a href="/">Navegação SGF</a></nav>
|
||||
<p>Cadastre apenas centros autorizados. Seleccione os centros já existentes; novas dotações exigem aprovação por outra identidade. Nenhum valor está predefinido.</p>
|
||||
<?php if ($contract['canCentre']) { ?><form id="centre"><h2>Novo centro</h2><label>Código <input name="code" maxlength="100" required></label><label>Fundamento <textarea name="reason" minlength="10" maxlength="500" required></textarea></label><button>Cadastrar centro</button></form><?php } ?>
|
||||
<?php if ($contract['canPrepare']) { ?><form id="budget"><h2>Preparar dotação</h2><label>Centro <select name="cost_center_id" id="centres" required></select></label><label>Exercício <input name="year" type="number" min="2026" max="2100" required></label><label>Natureza <select name="kind"><option value="operating">Funcionamento</option><option value="investment">Investimento</option></select></label><label>Montante MZN <input name="amount" inputmode="decimal" required></label><label><input type="checkbox" name="includes_tax"> Inclui imposto</label><label>Fundamento <textarea name="reason" minlength="3" maxlength="4000" required></textarea></label><button>Guardar dotação para aprovação</button></form><?php } ?>
|
||||
<h2>Dotações da unidade</h2><div id="budgets"></div><p id="result" role="status" aria-live="polite"></p>
|
||||
<script type="application/json" id="setup-contract"><?= json_encode($contract, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
|
||||
<script>
|
||||
const contract=JSON.parse(document.getElementById('setup-contract').textContent);const pending=new Map();
|
||||
async function api(path,body,key){const response=await fetch(path,{method:body?'POST':'GET',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':contract.csrf,'X-SGF-Context':contract.token,'X-Purchasing-Context':'procurement.internal','Idempotency-Key':key||''},...(body?{body:JSON.stringify(body)}:{})});const data=await response.json();if(!response.ok)throw new Error(data.message||'Não foi possível concluir.');return data;}
|
||||
async function command(path,body){const content=JSON.stringify([path,body]);let key=pending.get(content);if(!key){key=crypto.randomUUID();pending.set(content,key);}const result=await api(path,path.endsWith('/centres')?{...body,operation_key:key}:body,key);pending.delete(content);return result;}
|
||||
async function refresh(){const data=await api('/api/purchasing-budget');const centres=document.getElementById('centres');if(centres){centres.replaceChildren();for(const centre of data.centres){const option=document.createElement('option');option.value=centre.id;option.textContent=centre.code;centres.append(option);}}
|
||||
const money=value=>{const amount=BigInt(value);return `${amount/100n}.${(amount%100n).toString().padStart(2,'0')} MZN`;};
|
||||
const list=document.getElementById('budgets');list.replaceChildren();for(const budget of data.budgets){const row=document.createElement('p');const centre=data.centres.find(item=>Number(item.id)===Number(budget.cost_center_id));row.textContent=`Dotação ${budget.id} · ${centre?.code||budget.cost_center_id} · ${budget.year} · ${money(budget.amount_minor)} · ${budget.includes_tax?'Inclui imposto':'Não inclui imposto'} · ${budget.kind} · Preparada por ${data.actors[budget.created_by]||budget.created_by} · ${budget.reason} · ${budget.approved_by?'Aprovada':'Por aprovar'}`;if(contract.canApprove&&!budget.approved_by&&Number(budget.created_by)!==Number(contract.actor_id)){const button=document.createElement('button');button.textContent='Aprovar';button.onclick=()=>perform(async()=>{await command('/api/purchasing-budget/approve',{budget_id:budget.id});await refresh();});row.append(button);}list.append(row);}}
|
||||
async function perform(action){try{await action();document.getElementById('result').textContent='Operação concluída.';}catch(error){document.getElementById('result').textContent=error.message;}}
|
||||
const centre=document.getElementById('centre');if(centre)centre.onsubmit=event=>{event.preventDefault();void perform(async()=>{const form=new FormData(centre);await command('/api/internal-requisitions/centres',{code:form.get('code'),reason:form.get('reason')});centre.reset();await refresh();});};
|
||||
const budget=document.getElementById('budget');if(budget)budget.onsubmit=event=>{event.preventDefault();void perform(async()=>{const form=new FormData(budget);await command('/api/purchasing-budget/create',{cost_center_id:Number(form.get('cost_center_id')),year:Number(form.get('year')),kind:form.get('kind'),amount:form.get('amount'),includes_tax:form.has('includes_tax'),reason:form.get('reason')});budget.reset();await refresh();});};
|
||||
void perform(refresh);
|
||||
</script></main></body></html>
|
||||
@@ -4,3 +4,4 @@
|
||||
require_once __DIR__.'/integrations/sgf/operational/bootstrap.php';
|
||||
require_once __DIR__.'/integrations/sgf/operational/RuntimeContext.php';
|
||||
require_once __DIR__.'/integrations/sgf/operational/Composition.php';
|
||||
require_once __DIR__.'/integrations/sgf/sourcing/bootstrap.php';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Public immutable code assets only. Outside functional module/auth guards so
|
||||
// login and general navigation can load before a module is selected.
|
||||
Route::get('/build/modules/internal-requisitions/assets/{asset}', function (string $asset) {
|
||||
abort_unless(preg_match('/^[A-Za-z0-9_-]+\.(js|css)$/D', $asset), 404);
|
||||
$file = dirname(__DIR__).'/frontend/assets/'.$asset;
|
||||
abort_unless(is_file($file) && ! is_link($file), 404);
|
||||
|
||||
return response()->file($file, ['Content-Type' => str_ends_with($asset, '.js') ? 'application/javascript' : 'text/css', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable']);
|
||||
})->where('asset', '[A-Za-z0-9_-]+\.(js|css)');
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
use Tempo\Purchasing\Delivery\Composition;
|
||||
|
||||
require __DIR__.'/frontend-routes.php';
|
||||
Composition::routes(dirname(__DIR__, 3));
|
||||
if(config('purchasing-sourcing.enabled')===true)require dirname(__DIR__).'/sourcing/routes.php';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Modules\Identity\Http\RequireCompanyContext;
|
||||
use App\Modules\Identity\Http\RequireValidSession;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tempo\Purchasing\Delivery\BudgetSetup;
|
||||
|
||||
require_once __DIR__.'/BudgetSetup.php';
|
||||
Route::middleware(['web', 'auth', RequireValidSession::class, RequireCompanyContext::class])->group(function () {
|
||||
Route::get('/purchasing/internal-requisitions/budget-setup', [BudgetSetup::class, 'page']);
|
||||
Route::post('/api/internal-requisitions/centres', [BudgetSetup::class, 'centre']);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace Tempo\Purchasing\SgfSourcing;
|
||||
use Illuminate\Http\Request;
|
||||
use Tempo\Purchasing\Internal\RequisitionFailure;
|
||||
final class Controller
|
||||
{
|
||||
public function show(Request $request,int $id){return response()->json((new Workspace($request->user()))->read($id))->header('Cache-Control','private, no-store');}
|
||||
public function command(Request $request,int $id,string $action){
|
||||
try{return response()->json((new Workspace($request->user()))->command($id,$action,$request->all()));}
|
||||
catch(RequisitionFailure $e){return response()->json(['message'=>$e->getMessage()],$e->status);}
|
||||
catch(\InvalidArgumentException $e){return response()->json(['message'=>$e->getMessage()],422);}
|
||||
}
|
||||
public function page(Request $request,int $id){
|
||||
(new Workspace($request->user()))->read($id);
|
||||
$contract=['id'=>$id,'csrf'=>csrf_token(),'context'=>$request->session()->get('company_context.token')];
|
||||
ob_start();require __DIR__.'/screen.php';return response(ob_get_clean())->header('Cache-Control','private, no-store');
|
||||
}
|
||||
public function script(){return response(file_get_contents(__DIR__.'/screen.mjs'))->header('Content-Type','text/javascript');}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
namespace Tempo\Purchasing\SgfSourcing;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
|
||||
final class Schema
|
||||
{
|
||||
public const RIGHTS=['purchasing_sourcing.prepare','purchasing_sourcing.evaluate','purchasing_sourcing.propose','purchasing_sourcing.confirm'];
|
||||
public const TABLES=['ss_suppliers','ss_processes','ss_events','ss_commands'];
|
||||
public static function hash():string{return hash_file('sha256',__FILE__);}
|
||||
public static function signature(ConnectionInterface $db):string{return Installation::signature($db,[...Installation::tables(),...self::TABLES]);}
|
||||
public static function compatible(ConnectionInterface $db):bool{
|
||||
$row=$db->table('sgc_installations')->where('module','sourcing')->first();
|
||||
return $row&&$row->revision===1&&hash_equals($row->migration_sha256,self::hash())&&hash_equals($row->schema_signature,self::signature($db));
|
||||
}
|
||||
public static function migrate(ConnectionInterface $db,string $backupHash,string $operator):array{
|
||||
if($db->getDriverName()!=='pgsql'||!preg_match('/^[a-f0-9]{64}$/D',$backupHash)||trim($operator)==='')throw new \RuntimeException('Backup e operador obrigatórios.');
|
||||
return $db->transaction(function()use($db,$backupHash,$operator){
|
||||
$db->select("select pg_advisory_xact_lock(hashtext('tempo.sourcing.install.v1'))");
|
||||
if($db->table('sgc_installations')->where('module','sourcing')->exists()){
|
||||
if(!self::compatible($db))throw new \RuntimeException('Schema de sourcing divergente.');
|
||||
return ['prepared'=>true,'replayed'=>true];
|
||||
}
|
||||
$old=$db->table('sgc_installations')->where('module','internal-requisitions')->first();
|
||||
if(!$old||$old->revision!==2||!hash_equals($old->schema_signature,Installation::signature($db,Installation::tables())))throw new \RuntimeException('Conferir schema anterior antes de ampliar catálogo.');
|
||||
$schema=$db->getSchemaBuilder();
|
||||
foreach(self::TABLES as $table){
|
||||
if($schema->hasTable($table))throw new \RuntimeException('Schema parcial: não sobrescrever.');
|
||||
$schema->create($table,function(Blueprint $t)use($table){
|
||||
$t->id();foreach(['tenant_id','company_id','unit_id'] as $key)$t->unsignedBigInteger($key);
|
||||
if($table==='ss_suppliers'){$t->string('reference');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','reference']);}
|
||||
elseif($table==='ss_processes'){$t->unsignedBigInteger('request_id');$t->unsignedInteger('version');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','request_id']);}
|
||||
elseif($table==='ss_events'){$t->unsignedBigInteger('request_id');$t->unsignedInteger('version');$t->unsignedBigInteger('actor_id');$t->string('action');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','request_id','version']);}
|
||||
else{$t->unsignedBigInteger('actor_id');$t->uuid('operation_key');$t->string('request_hash',64);$t->jsonb('result');$t->unique(['tenant_id','company_id','unit_id','actor_id','operation_key']);}
|
||||
$t->timestampTz('created_at');
|
||||
});
|
||||
$db->statement("ALTER TABLE $table ADD FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)");
|
||||
$db->statement("ALTER TABLE $table ADD UNIQUE(tenant_id,company_id,unit_id,id)");
|
||||
}
|
||||
foreach(['ss_processes','ss_events'] as $table)$db->statement("ALTER TABLE $table ADD FOREIGN KEY(tenant_id,company_id,unit_id,request_id) REFERENCES ir_requests(tenant_id,company_id,unit_id,id)");
|
||||
foreach(['ss_events','ss_commands'] as $table){
|
||||
$db->statement("ALTER TABLE $table ADD FOREIGN KEY(actor_id) REFERENCES users(id)");
|
||||
$db->unprepared("CREATE FUNCTION {$table}_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve sourcing history'; END; \$\$; CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON $table FOR EACH ROW EXECUTE FUNCTION {$table}_immutable();");
|
||||
}
|
||||
$db->statement("ALTER TABLE ss_processes ADD CHECK(payload->'confirmed' IS NULL OR payload->'confirmed'='null'::jsonb OR (payload->'confirmed'->>'confirmed_by')::bigint<>(payload->'confirmed'->'proposal'->>'proposed_by')::bigint)");
|
||||
$db->unprepared(<<<'SQL'
|
||||
CREATE FUNCTION ss_process_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE requester bigint;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Preserve sourcing origin'; END IF;
|
||||
IF (NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.request_id) IS DISTINCT FROM (OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.request_id) OR NEW.payload->'origin' IS DISTINCT FROM OLD.payload->'origin' THEN RAISE EXCEPTION 'Immutable sourcing origin'; END IF;
|
||||
IF NEW.version<>OLD.version+1 THEN RAISE EXCEPTION 'Sourcing version must advance once'; END IF;
|
||||
IF OLD.payload->'confirmed' IS NOT NULL AND OLD.payload->'confirmed'<>'null'::jsonb THEN RAISE EXCEPTION 'Confirmed award immutable'; END IF;
|
||||
SELECT requester_id INTO requester FROM ir_requests WHERE id=NEW.request_id;
|
||||
IF NEW.payload->'confirmed' IS NOT NULL AND NEW.payload->'confirmed'<>'null'::jsonb AND (requester=(NEW.payload->'confirmed'->>'confirmed_by')::bigint OR requester=(NEW.payload->'confirmed'->'proposal'->>'proposed_by')::bigint) THEN RAISE EXCEPTION 'Requester cannot award'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ss_process_guard BEFORE UPDATE OR DELETE ON ss_processes FOR EACH ROW EXECUTE FUNCTION ss_process_guard();
|
||||
SQL);
|
||||
$c=$db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
|
||||
if(!preg_match('/^CHECK\s*\((.*)\)$/s',$c->definition,$match))throw new \RuntimeException('Catálogo desconhecido.');
|
||||
$rights=implode(',',array_map(fn($r)=>$db->getPdo()->quote($r).'::text',self::RIGHTS));
|
||||
$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 (({$match[1]}) OR (unit_id IS NOT NULL AND permission::text IN ($rights)))");
|
||||
$db->table('sgc_installations')->insert(['module'=>'sourcing','revision'=>1,'schema_signature'=>self::signature($db),'migration_sha256'=>self::hash(),'backup_sha256'=>$backupHash,'operator'=>$operator,'database_operator'=>$db->selectOne('select current_user as role')->role,'prepared_at'=>now()]);
|
||||
return ['prepared'=>true,'replayed'=>false,'tables'=>self::TABLES,'access_granted'=>false];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
namespace Tempo\Purchasing\SgfSourcing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tempo\Purchasing\SgfCandidate\Port;
|
||||
use Tempo\Purchasing\Sourcing\Sourcing;
|
||||
use Tempo\Purchasing\Internal\RequisitionFailure;
|
||||
|
||||
final class Workspace
|
||||
{
|
||||
private Port $port;
|
||||
public function __construct(private User $actor){$this->port=new Port($actor);}
|
||||
private function q(string $table){return DB::table('ss_'.$table)->where($this->port->scope());}
|
||||
private function auth(string $action):void{
|
||||
$this->port->authorize('view',null);
|
||||
if($action==='view')return;
|
||||
$right=match($action){'select','quote'=>'prepare','evaluate'=>'evaluate','propose'=>'propose','confirm'=>'confirm',default=>abort(404)};
|
||||
$s=$this->port->scope();app(Permissions::class)->require($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);
|
||||
}
|
||||
private function policy(array $state=[]):array{return $state['policy']??json_decode(file_get_contents(__DIR__.'/comparison-defaults.json'),true,32,JSON_THROW_ON_ERROR);}
|
||||
private function state(int $id):array{
|
||||
$row=$this->q('processes')->where('request_id',$id)->lockForUpdate()->first();
|
||||
$state=$row?json_decode($row->payload,true,64,JSON_THROW_ON_ERROR):['version'=>0,'suppliers'=>[],'quotes'=>[],'proposals'=>[],'confirmed'=>null];
|
||||
$s=$this->port->scope();
|
||||
foreach($state['quotes'] as $key=>&$quote){
|
||||
$supplier=$this->q('suppliers')->where('id',$key)->first();
|
||||
$payload=$supplier?json_decode($supplier->payload,true):[];
|
||||
$quote['supplier_eligible']=($payload['active']??false)&&!($payload['blocked']??true)&&($payload['review_until']??'')>=date('Y-m-d');
|
||||
foreach($quote['evaluations'] as &$evaluation){
|
||||
$id=$evaluation['evaluator_id'];
|
||||
$evaluation['eligible']=DB::table('company_memberships')->where(['user_id'=>$id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists()
|
||||
&&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'unit.view',$s['unit_id'])
|
||||
&&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.evaluate',$s['unit_id']);
|
||||
}unset($evaluation);
|
||||
}unset($quote);
|
||||
return $state;
|
||||
}
|
||||
public function read(int $id):array{return $this->port->atomic(function()use($id){
|
||||
abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
|
||||
$this->auth('view');$r=$this->port->load($id);$this->port->authorize('view',$r);$s=$this->state($id);
|
||||
return ['request'=>$r,'state'=>$s,'policy'=>$this->policy($s),'comparison'=>(new Sourcing)->comparison($s,$this->policy($s),date('Y-m-d')),'suppliers'=>$this->q('suppliers')->orderBy('id')->get(),'history'=>$this->q('events')->where('request_id',$id)->orderBy('version')->get(),'rights'=>$this->rights()];
|
||||
});}
|
||||
private function rights():array{
|
||||
$s=$this->port->scope();$result=[];foreach(['prepare','evaluate','propose','confirm'] as $right)$result[$right]=app(Permissions::class)->has($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);return $result;
|
||||
}
|
||||
public function command(int $id,string $action,array $input):array{return $this->port->atomic(function()use($id,$action,$input){
|
||||
abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
|
||||
$this->auth($action);$r=$this->port->load($id);$this->port->authorize('view',$r);
|
||||
$meta=Validator::make($input,['operation_key'=>'required|uuid','version'=>'required|integer|min:0'])->validate();
|
||||
$meta['version']=(int)$meta['version'];
|
||||
$hash=hash('sha256',json_encode([$id,$action,$input],JSON_THROW_ON_ERROR));
|
||||
$old=$this->q('commands')->where('actor_id',$this->actor->id)->where('operation_key',$meta['operation_key'])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com outros dados.');return json_decode($old->result,true);}
|
||||
$state=$this->state($id);abort_unless($state['version']===$meta['version'],409,'Versão do processo alterada.');
|
||||
$origin=['request_id'=>$r['id'],'origin_key'=>$r['origin_key'],'request_version'=>$r['version'],'budget_origin_id'=>$r['budget_origin_id'],'snapshot_hash'=>hash('sha256',json_encode([$r['data'],$r['approval_snapshot'],$r['reservation']],JSON_THROW_ON_ERROR))];
|
||||
if($state['version']===0){$state['origin']=$origin;$state['policy']=$this->policy();}else abort_unless(($state['origin']??null)==$origin,409,'A origem mudou; reconcilie o processo sem criar outra reserva.');
|
||||
$rules=match($action){
|
||||
'select'=>['supplier_id'=>'required|integer|min:1'],
|
||||
'quote'=>['supplier_id'=>'required|integer|min:1','reference'=>'required|string|max:100','currency'=>'required|in:MZN','valid_until'=>'required|date_format:Y-m-d','delivery_days'=>'required|integer|between:1,3650','payment_terms'=>'required|string|min:3|max:500','items'=>'required|array|min:1|max:100','items.*.line'=>'required|integer|min:0','items.*.unit_price'=>'required|string|regex:/^\d{1,10}(\.\d{1,2})?$/','items.*.discount_type'=>'sometimes|in:none,percent,amount','items.*.discount_value'=>'sometimes|string|regex:/^\d{1,10}(\.\d{1,2})?$/'],
|
||||
'evaluate'=>['supplier_id'=>'required|integer|min:1','quote_revision'=>'required|integer|min:1','quality'=>'required|numeric|between:0,10','history'=>'required|numeric|between:0,10','payment'=>'required|numeric|between:0,10','no_conflict'=>'accepted','reason'=>'required|string|min:10|max:2000'],
|
||||
'propose'=>['supplier_id'=>'required|integer|min:1','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'],
|
||||
'confirm'=>['proposal_index'=>'required|integer|min:0','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000'],default=>abort(404)};
|
||||
$data=Validator::make($input,$rules)->validate();
|
||||
foreach(['supplier_id','quote_revision','proposal_index','delivery_days'] as $field)if(isset($data[$field]))$data[$field]=(int)$data[$field];
|
||||
if(isset($data['items']))foreach($data['items'] as &$line)$line['line']=(int)$line['line'];unset($line);
|
||||
if($action==='select'){
|
||||
$supplier=$this->q('suppliers')->where('id',$data['supplier_id'])->lockForUpdate()->first();abort_unless($supplier,404);
|
||||
$payload=json_decode($supplier->payload,true);
|
||||
$data['supplier']=['id'=>$supplier->id,'reference'=>$supplier->reference,'snapshot'=>$payload,'eligible'=>$payload['active']&&!$payload['blocked']&&$payload['review_until']>=date('Y-m-d')];
|
||||
}
|
||||
if(in_array($action,['propose','confirm'],true)){
|
||||
$supplier=$action==='propose'?$data['supplier_id']:(end($state['proposals'])['supplier_id']??null);
|
||||
$quote=$state['quotes'][$supplier]??null;abort_unless($quote,422,'Cotação em falta.');
|
||||
// Do not invent an automatic budget revision or create another reserve.
|
||||
$budget=DB::table('cb_budgets')->where($this->port->scope())->where('id',$r['reservation']['budget_id']??0)->lockForUpdate()->first();
|
||||
$entries=DB::table('cb_budget_entries')->where($this->port->scope())->where('requisition_id',$r['budget_origin_id']);
|
||||
$basis=$budget?($budget->includes_tax?$quote['total_minor']:$quote['subtotal_minor']):null;
|
||||
abort_unless($budget&&$budget->approved_by&&$basis===(int)(clone $entries)->sum('reserved_minor')&&(int)(clone $entries)->sum('committed_minor')===0,409,'A alteração do valor exige adaptação orçamental ainda não integrada neste percurso. Nenhum movimento foi criado.');
|
||||
$rules=$r['approval_snapshot'];$approvalAmount=$rules['approval_includes_tax']?$quote['total_minor']:$quote['subtotal_minor'];
|
||||
abort_unless($approvalAmount<=$rules['first_limit_minor'],409,'A cadeia de adjudicação acima do primeiro limite ainda requer integração dos níveis existentes de Compras.');
|
||||
if($action==='confirm'){
|
||||
$members=$this->port->setup()['members'];
|
||||
abort_unless(count(array_filter($members,fn($member)=>$member['user_id']===$this->actor->id&&$member['department_ref']===$r['department_ref']))===1,403,'Confirmação exige atribuição ao departamento da origem.');
|
||||
}
|
||||
}
|
||||
$next=(new Sourcing)->transition($state,$r,$this->actor->id,$action,$data,$this->policy($state),date('Y-m-d'));
|
||||
$next['version']=$state['version']+1;$now=now();$scope=$this->port->scope();
|
||||
$values=['version'=>$next['version'],'payload'=>json_encode($next,JSON_THROW_ON_ERROR)];
|
||||
if($state['version']===0)$this->q('processes')->insert($scope+['request_id'=>$id,...$values,'created_at'=>$now]);else $this->q('processes')->where('request_id',$id)->update($values);
|
||||
$this->q('events')->insert($scope+['request_id'=>$id,'version'=>$next['version'],'actor_id'=>$this->actor->id,'action'=>$action,'payload'=>json_encode(['before_version'=>$state['version'],'input'=>$data,'state'=>$next],JSON_THROW_ON_ERROR),'created_at'=>$now]);
|
||||
$failFile=storage_path('framework/sourcing-failure.fixture');clearstatcache(true,$failFile);
|
||||
if(app()->environment('testing')&&preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',DB::connection()->getDatabaseName())&&is_file($failFile))throw new \RuntimeException('Injected sourcing persistence failure');
|
||||
$result=['request_id'=>$id,'version'=>$next['version'],'confirmed'=>$next['confirmed']!==null];
|
||||
$this->q('commands')->insert($scope+['actor_id'=>$this->actor->id,'operation_key'=>$meta['operation_key'],'request_hash'=>$hash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>$now]);
|
||||
return $result;
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
foreach(['QuotationPricing','ProposalScoring'] as $class)require_once dirname(__DIR__,3).'/packages/purchasing/src/'.$class.'.php';
|
||||
require_once dirname(__DIR__,3).'/packages/purchasing/src/Sourcing.php';
|
||||
foreach(['Schema','Workspace','Controller'] as $class)require_once __DIR__.'/'.$class.'.php';
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Identity\Http\RequireValidSession;
|
||||
use App\Modules\Identity\Http\RequireCompanyContext;
|
||||
use Tempo\Purchasing\Delivery\RuntimeContext;
|
||||
use Tempo\Purchasing\SgfSourcing\Controller;
|
||||
Route::middleware(['web','auth',RequireValidSession::class,RequireCompanyContext::class,RuntimeContext::class])->group(function(){
|
||||
Route::get('/purchasing/sourcing/screen.mjs',[Controller::class,'script']);
|
||||
Route::get('/purchasing/sourcing/{id}',[Controller::class,'page'])->whereNumber('id');
|
||||
Route::get('/api/sourcing/{id}',[Controller::class,'show'])->whereNumber('id');
|
||||
Route::post('/api/sourcing/{id}/{action}',[Controller::class,'command'])->whereNumber('id')->whereIn('action',['select','quote','evaluate','propose','confirm']);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const c=JSON.parse(document.querySelector('#contract').textContent);let data;
|
||||
const el=id=>document.getElementById(id),money=v=>(v/100).toLocaleString('pt-MZ',{minimumFractionDigits:2,maximumFractionDigits:2})+' MZN';
|
||||
async function api(action,input){const r=await fetch('/api/sourcing/'+c.id+(action?'/'+action:''),{method:action?'POST':'GET',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':c.csrf,'X-SGF-Context':c.context,'X-Purchasing-Context':'procurement.internal'},...(action?{body:JSON.stringify({...input,version:data.state.version,operation_key:crypto.randomUUID()})}:{})});const d=await r.json();if(!r.ok)throw Error(d.message??JSON.stringify(d.errors));return d;}
|
||||
function options(select,rows){select.replaceChildren(...rows.map(([id,text])=>{const o=document.createElement('option');o.value=id;o.textContent=text;return o;}));}
|
||||
function textNode(tag,text){const node=document.createElement(tag);node.textContent=text;return node;}
|
||||
async function refresh(){data=await api();el('origin').textContent=`Requisição ${data.request.id}, versão ${data.request.version}, ${money(data.request.total_minor)}; processo versão ${data.state.version}. Mínimos: ${data.policy.minimum_proposals} fornecedores e ${data.policy.minimum_evaluators} avaliadores.`;
|
||||
const table=document.createElement('table');table.append(textNode('caption',data.comparison.ready?'Comparação completa':'Comparação incompleta: recolher propostas e avaliações elegíveis'));const head=document.createElement('tr');for(const s of ['Fornecedor','Revisão','Valor','Entrega','Pagamento','Avaliações','Pontuação'])head.append(textNode('th',s));table.append(head);
|
||||
for(const q of data.comparison.rows){const row=document.createElement('tr');for(const s of [data.state.suppliers[q.supplier_id]?.snapshot?.name??q.supplier_id,q.revision,money(q.total_minor),q.delivery_days,q.payment_terms,q.evaluation_count,data.comparison.scores[q.id]===undefined?'—':(data.comparison.scores[q.id]/100).toFixed(2)])row.append(textNode('td',String(s)));table.append(row);}el('comparison').replaceChildren(table);
|
||||
const supplierRows=data.suppliers.map(s=>[s.id,JSON.parse(typeof s.payload==='string'?s.payload:JSON.stringify(s.payload)).name]);options(el('select').elements.supplier_id,supplierRows);
|
||||
const selected=Object.entries(data.state.suppliers).map(([id,s])=>[id,s.snapshot.name]);options(el('quote').elements.supplier_id,selected);
|
||||
const quotes=data.comparison.rows.map(q=>[q.supplier_id,`${data.state.suppliers[q.supplier_id].snapshot.name} · revisão ${q.revision}`]);options(el('evaluation').elements.supplier_id,quotes);options(el('proposal').elements.supplier_id,quotes);
|
||||
el('lines').replaceChildren(...data.request.data.items.map((line,i)=>{const label=document.createElement('label');label.textContent=`${line.description} · ${line.quantity} · imposto ${line.tax_rate}% `;const input=document.createElement('input');input.name='price_'+i;input.required=true;input.inputMode='decimal';label.append(input);return label;}));
|
||||
for(const right of ['prepare','evaluate','propose','confirm'])el(right).hidden=!data.rights[right]||data.state.confirmed!==null;
|
||||
el('proposals').textContent=JSON.stringify(data.state.proposals,null,2);el('history').replaceChildren(...data.history.map(e=>textNode('p',`Versão ${e.version} · ${e.action} · identidade ${e.actor_id} · ${e.created_at}`)));
|
||||
if(data.state.confirmed)el('message').textContent='Adjudicação confirmada. Processo fechado.';
|
||||
}
|
||||
async function submit(form,action,make){form.addEventListener('submit',async e=>{e.preventDefault();try{await api(action,make(new FormData(form)));el('message').textContent='Registado.';await refresh();}catch(error){el('message').textContent=error.message;}});}
|
||||
submit(el('select'),'select',f=>({supplier_id:Number(f.get('supplier_id'))}));
|
||||
submit(el('quote'),'quote',f=>({supplier_id:Number(f.get('supplier_id')),reference:f.get('reference'),currency:'MZN',valid_until:f.get('valid_until'),delivery_days:Number(f.get('delivery_days')),payment_terms:f.get('payment_terms'),items:data.request.data.items.map((_,i)=>({line:i,unit_price:f.get('price_'+i)}))}));
|
||||
submit(el('evaluation'),'evaluate',f=>({supplier_id:Number(f.get('supplier_id')),quote_revision:data.state.quotes[f.get('supplier_id')].revision,quality:Number(f.get('quality')),history:Number(f.get('history')),payment:Number(f.get('payment')),no_conflict:f.get('no_conflict')==='on',reason:f.get('reason')}));
|
||||
submit(el('proposal'),'propose',f=>({supplier_id:Number(f.get('supplier_id')),comparison_hash:data.comparison.fingerprint,reason:f.get('reason'),delivery_date:f.get('delivery_date'),delivery_location:f.get('delivery_location')}));
|
||||
submit(el('confirmation'),'confirm',f=>({proposal_index:data.state.proposals.length-1,comparison_hash:data.comparison.fingerprint,reason:f.get('reason')}));
|
||||
el('refresh').onclick=()=>refresh().catch(e=>el('message').textContent=e.message);refresh().catch(e=>el('message').textContent=e.message);
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Consulta de fornecedores e adjudicação</title>
|
||||
<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto;padding:1rem}label{display:block;margin:.7rem 0}input,select,textarea,button{font:inherit;padding:.5rem}textarea{width:95%;min-height:4rem}table{border-collapse:collapse;width:100%}td,th{border:1px solid #aaa;padding:.5rem}section{margin:2rem 0}#message{white-space:pre-wrap}</style>
|
||||
<a href="/purchasing/internal-requisitions">Requisições</a><h1>Consulta de fornecedores e adjudicação</h1><p>Compras internas · contexto activo da empresa e unidade seleccionadas. Registo local de cotações; não envia pedidos a fornecedores.</p><p id="message" role="status"></p><button id="refresh">Actualizar</button>
|
||||
<section><h2>Origem e comparação</h2><div id="origin"></div><div id="comparison"></div></section>
|
||||
<section id="prepare" hidden><h2>Seleccionar fornecedor</h2><form id="select"><select name="supplier_id" required></select><button>Seleccionar</button></form>
|
||||
<h2>Registar ou rever cotação</h2><form id="quote"><label>Fornecedor seleccionado <select name="supplier_id" required></select></label><label>Referência <input name="reference" required maxlength="100"></label><label>Validade <input name="valid_until" type="date" required></label><label>Prazo de entrega em dias <input name="delivery_days" type="number" min="1" required></label><label>Condições de pagamento <textarea name="payment_terms" required></textarea></label><div id="lines"></div><p>Preços unitários em MZN. Quantidades e impostos preservam as linhas da requisição.</p><button>Guardar cotação</button></form></section>
|
||||
<section id="evaluate" hidden><h2>Avaliação da versão da cotação</h2><form id="evaluation"><select name="supplier_id" required></select><label>Qualidade (0–10)<input name="quality" type="number" min="0" max="10" step=".01" required></label><label>Histórico (0–10)<input name="history" type="number" min="0" max="10" step=".01" required></label><label>Pagamento (0–10)<input name="payment" type="number" min="0" max="10" step=".01" required></label><label><input name="no_conflict" type="checkbox" required> Declaro ausência de conflito de interesses</label><label>Fundamento <textarea name="reason" minlength="10" required></textarea></label><button>Guardar avaliação</button></form></section>
|
||||
<section id="propose" hidden><h2>Propor adjudicação</h2><form id="proposal"><select name="supplier_id" required></select><label>Justificação <textarea name="reason" minlength="10" required></textarea></label><label>Data de entrega prevista <input name="delivery_date" type="date" required></label><label>Local de entrega previsto <input name="delivery_location" minlength="3" required></label><button>Propor com a comparação apresentada</button></form></section>
|
||||
<section id="confirm" hidden><h2>Confirmação independente</h2><form id="confirmation"><label>Fundamento <textarea name="reason" minlength="10" required></textarea></label><button>Confirmar a proposta apresentada</button></form></section>
|
||||
<section><h2>Propostas de adjudicação preservadas</h2><pre id="proposals"></pre><h2>Histórico por versão</h2><div id="history"></div></section>
|
||||
<script id="contract" type="application/json"><?=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/sourcing/screen.mjs"></script></html>
|
||||
Reference in New Issue
Block a user