This commit is contained in:
2026-10-29 18:43:29 +02:00
parent f77900e198
commit 78e89a51d2
75 changed files with 2881 additions and 14 deletions
+3
View File
@@ -30,3 +30,6 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
/storage/framework/budget-demo/
/storage/framework/budget-proof/
/storage/framework/payables/
+28
View File
@@ -1,7 +1,33 @@
# SGF Tempo — instruções locais
- Implementação autorizada em 29/10/2026: `COMMON-PAYABLE-SOURCE-SIM-v1`, cenário aprovado 10 000+1 600=11 600 MZN, fonte única `packages/payables`, factura efectivamente conferida por Compras, preparação/validação distintas e consulta nas duas composições QA. Principal reconhecido com vencimento/dias nulos e atraso indeterminado. Ver `docs/SUPPLIER_PAYABLE_DELIVERY.md`. Pagamentos anteriores impedem incorporação integral; preservar originais/histórico e passagem de responsabilidade sem segundo saldo. Bases/roles próprias, sem alterar demos em 8002/8003 ou dados/acessos reais. Sem pagamentos/cobranças/afectações/stock/orçamento adicional/contabilidade, dependências novas, push/publicação, aceitação ou checkpoint presumidos.
- Orientação comum de produto da Tempo, 29/10/2026: oito regras de documento/dívida/liquidação, condições explícitas, afectações parciais, confirmação/reconciliação, idempotência, correcções, cobrança configurada e responsabilidade financeira única registadas em `docs/SUPPLIER_PAYABLE_SCOPE.md`, secção 14. São separadas da fiscalidade específica e não declaram capacidades futuras entregues. O recorte actual permanece só obrigação a fornecedor; sem pagamentos/cobranças/lançamentos por arrasto. Vencimento desconhecido é pendência documental visível, não data inventada, dívida apagada ou pré-condição universal de reconhecimento criada por escolha técnica. Preservar a avaliação orçamental em paralelo.
- Orientação de 29/10/2026 para a ligação seguinte: conferir/delimitar `COMMON-PAYABLE-SOURCE-SIM-v1`, factura de fornecedor conferida → obrigação a pagar única, MZN, vencimento/origem/revisão/histórico, sem redigitar dados válidos. Ver `docs/SUPPLIER_PAYABLE_SCOPE.md`. Preservar o saldo implícito já calculado por Compras: a nova capacidade não pode criar autoridade concorrente para a mesma dívida. Documento incompatível/incompleto fica pendente; correcções preservam original. Nesta passagem apenas leitura/desenho, sem implementação, pagamento, stock, lançamento, migração/concessão real ou novo checkpoint. Orçamento aceite e avaliação da equipa em paralelo, sem resets.
- Incremento autorizado em 29/10/2026: `SGF-BUDGET-WORKSPACE-SIM-v1`, interface orçamental SGF sobre o núcleo comum aceite, com quatro funções sintéticas e rastreabilidade. Ver `docs/SGF_BUDGET_WORKSPACE_DELIVERY.md`. Demonstração persistente isolada em loopback 8003 e base `budget_core_sgf_<12 hex>`/role QA própria restrita; provas descartáveis separadas em 8004. Preservar a demonstração anterior em 8002/`sgf_tempo_test`, dados/contas reais e orçamento industrial. Sem nova cópia do motor, migração/concessão real, uso real, obrigações/pagamentos/contabilidade ou aceitação/checkpoint presumidos. A interface está implementada; revisão visual da equipa permanece separada das provas HTTP/build.
- Aceitação expressa de 29/10/2026: `COMMON-BUDGET-SYNTHETIC-v1` aceite como extracção técnica do núcleo comum e checkpoint exclusivamente local autorizado nos dois repositórios. Ver `docs/COMMON_BUDGET_ACCEPTANCE.md`. Preservar revisão visual de Compras pendente, SGF apenas pelo adaptador QA sem interface deste percurso e cobertura dos dois testes MFA pendente. Automação do navegador não bloqueia desenvolvimento. Próximo passo: percurso sintético integrado no SGF para a equipa, reutilizando o núcleo. Sem utilização real, migração/concessão real, push/publicação ou avanço para obrigações/pagamentos/contabilidade por arrasto.
- Autorização técnica de 29/10/2026: implementar `COMMON-BUDGET-SYNTHETIC-v1`, fonte única em `packages/budget-control`, adaptadores Compras/SGF e provas nos dois hosts isolados. Ver `docs/COMMON_BUDGET_DELIVERY.md`. Nenhuma actualização de dependências, migração/concessão/dado real, publicação, push ou checkpoint de encerramento. SGF mantém guarda QA do adaptador; `sgf_tempo_test` e demonstração da equipa preservados. Não avançar para obrigações/pagamentos/contabilidade. Verificação técnica não constitui revisão visual ou aceitação.
## Decisão expressa da Tempo — 29/10/2026: Compras e núcleo financeiro comum
Um módulo de Compras, um núcleo financeiro comum e duas composições comerciais: ERP SGF com Compras e Compras autónomo com as capacidades comuns necessárias, sem exigir facturação de vendas SGF ou SGF remoto. Substitui a proposta principal de duas aplicações ligadas por conector remoto. Preservar os percursos de `C:/laragon/www/compras`; extrair/adaptar o existente, sem segundo motor financeiro. Cada efeito tem um único serviço responsável. Ver `docs/PURCHASING_COMMON_CORE_PLAN.md`: percursos preservados, capacidades existentes/em falta, transição e primeiro incremento `COMMON-BUDGET-SYNTHETIC-v1`. Nesta passagem autoriza-se o registo/desenho; a reestruturação segue o incremento delimitado, sem alterações reais por arrasto. Manter instruções anteriores, dados, acessos e demonstração; desenvolvimento/provas com identidades e bases sintéticas isoladas.
## Decisão expressa da Tempo — 29/10/2026: produto, demonstração e implantação
Esta decisão prevalece sobre dependências anteriores de pessoas reais e aprovações de cada incremento técnico. Desenvolvimento e testes usam empresas, unidades, utilizadores e operações sintéticas, com permissões explícitas, isolamento e separação entre preparador, técnico, verificador e validador. Osmane, Samira e concessões na Tempo não são pré-condições para construir ou testar o produto. Referências anteriores a essas pessoas descrevem a implantação/configuração histórica da Tempo, não papéis fixos do SGF.
A próxima entrega é uma demonstração integrada estável para a equipa experimentar percursos completos com exemplos e propor correcções. Agrupar revisões em entregas funcionais; não pedir aprovação de cada pequeno passo técnico. Desenvolver módulos e ligações em falta no contexto sintético. As aceitações anteriores mantêm o seu âmbito; esta autorização não equivale à aceitação de funcionalidades ainda não demonstradas.
Pessoas reais, concessões efectivas, dados empresariais e configurações fiscais/contabilísticas aplicáveis pertencem à implantação no cliente. Preservar tudo o que já foi configurado na Tempo, sem novas concessões reais por consequência da demonstração. Conformidade de emissão fiscal é necessária antes do uso real e não bloqueia o restante desenvolvimento. Preservar originais, histórico, checkpoints, permissões e guardas contra utilização real. Ver `docs/PRODUCT_DEMO.md` para inventário, percursos e limitações.
- Autorização técnica de 28/10/2026: implementar `WORKSHOP-BILLING-CORRECTION-SIM-v1` conforme `docs/WORKSHOP_BILLING_CORRECTION_SCOPE.md`, só sintético em `sgf_tempo_test`. Substituição imutável 500→510 MZN sem imposto (material 80/UN, trabalho 200/H), revisão distinta, origens únicas; cancelamento sem libertar origens/reabrir cobrança. Bloquear destinos substituídos/cancelados em A/B1 e manter originais, decisões e fotografias. `130000` e cinco concessões reais apenas propostas, não aplicar sem confirmação específica. Preservar `3932b94`, `30029b9`, `d071bc0`, anteriores e alterações posteriores. Sem uso real, stock, emissão, lançamentos, publicação, aceitação presumida ou checkpoint de encerramento. Ver `docs/WORKSHOP_BILLING_CORRECTION_DELIVERY.md`.
- Decisões validadas em 28/10/2026: correcção/cancelamento de rascunhos de oficina conforme `docs/WORKSHOP_BILLING_CORRECTION_SCOPE.md`, secção 8. Substituição imutável por novo par, originais preservados; mesmo cliente/moeda/origens/quantidades, correcções comerciais delimitadas; cancelamento sem libertar origens ou reabertura; Osmane prepara/aplica e Samira revê distintamente. Ensaio sintético: material 80 MZN/UN e trabalho 200 MZN/H, soma 510 sem imposto versus original 500. Apenas desenho validado; implementação, migrações/concessões, prova e aceitação futuras separadas. Preservar checkpoints e bloqueios, sem uso real/publicação.
- Autorização específica posterior de 28/10/2026 executada: apenas `110000` aplicada em `sgf_tempo_dev`, por caminho exclusivo com `sgf_dev_owner`, e sete concessões auditadas no tenant #1 / Tempo Lda #1 / SEGI #1: conta #1 `industrial_budgets.view/prepare`, `industrial_budget_comparisons.view/prepare`; conta #2 `industrial_budgets.view/validate`, `industrial_budget_comparisons.view`. Ver actualização de `docs/INDUSTRIAL_BUDGET_DELIVERY.md`. Preservadas 81 tabelas anteriores, acessos e instalação `120000`; seis tabelas novas vazias. Aplicação mantém `sgf_dev_app`; orçamento/conversão/oficinas/custos/recebimentos/emissão reais bloqueados, guardas SQL verificadas. Indicações anteriores de `110000`/sete concessões por aplicar são históricas. Sem utilização real ou publicação.
- Autorização específica posterior de 28/10/2026 executada: apenas `120000` aplicada em `sgf_tempo_dev`, por caminho exclusivo com `sgf_dev_owner`, e seis concessões auditadas no tenant #1 / Tempo Lda #1 / SEGI #1: conta #1 `workshop_billing.view/prepare/generate`; conta #2 `workshop_billing.view/review` e `workshop.view`. Ver actualização de `docs/WORKSHOP_BILLING_DELIVERY.md`. Preservadas 74 tabelas anteriores e acessos; sete tabelas novas vazias. Aplicação mantém `sgf_dev_app`; conversão/oficinas/custos/recebimentos/emissão reais bloqueados e guardas SQL verificadas. `110000` e sete concessões do orçamento permanecem pendentes; não aplicar por arrasto. Consulta de oficina da conta #2 não concede apontamento/verificação/supervisão. Indicações anteriores de `120000`/seis concessões por aplicar são históricas; sem utilização real ou publicação.
@@ -87,3 +113,5 @@
- Em 25 de Outubro de 2026, o utilizador aceitou B1 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos, e autorizou um checkpoint Git local sem credenciais ou dados locais. Preservar B1 e os checkpoints anteriores. Não avançar para os restantes incrementos nem publicar.
- Autorização posterior de 26 de Outubro de 2026: preparação documental de emitente/clientes/rascunhos e instruções de pagamento reutilizáveis do emitente, sem emissão, recebimento ou contabilização. Preservar B1 `8fcd52c`; não reinterpretar preços antigos ou modificar fotografias existentes. Esquema 2 apenas em novas confirmações que utilizem preparação documental. Novos direitos issuer_documents/client_documents view/edit na empresa e draft_documents view/edit na unidade são explícitos; as nove concessões para contas existentes 1 (consulta/edição) e 2 (consulta), empresa 1/unidade 1, foram especificamente confirmadas. Não inferir poderes de gestão desses direitos. Nunca incluir valores completos das instruções em logs técnicos, relatórios de provas ou repositório. Ver docs/DOCUMENT_PREPARATION_DELIVERY.md.
- Autorização posterior: primeira pré-visualização apenas no navegador, aviso fixo «RASCUNHO — SEM VALIDADE FISCAL», configuração delimitada e história B1 exclusivamente da fotografia. Não implementar emissão, cálculos fiscais, PDF/exportação/impressão documental ou operações financeiras. Consultas de pré-visualização são coerentes e read-only; mudanças geram aviso e actualização explícita. Novo direito document_preview.configure requer confirmação específica antes de concessão real; a operação para conta 1/empresa 1 foi especificamente confirmada e aplicada em 26 de Outubro de 2026. Samira permanece apenas em consulta. Novas operações não podem ser aplicadas por inferência. Ver docs/DRAFT_INVOICE_PREVIEW_DELIVERY.md.
- Orientação expressa da Tempo, 29/10/2026: o SGF tem fiscalidade transversal a vendas/compras, stocks, contabilidade geral/custos, análises financeiras e preenchimento automático de modelos IVA/IRPS/IRPC/INSS e outros aplicáveis. Reutilizar o conhecimento já tratado no InvesteMoz (fontes, regras, decisões, correspondências e casos) sem duplicar investigação, acrescentando as capacidades operacionais próprias do SGF. Ausência de prova na auditoria inicial não exclui conhecimento existente nem o requisito. Ver docs/SERVICE_INVOICE_CALCULATION_SPEC.md, secção 11, FIS-SGF-13 a 19. Esta orientação regista âmbito de produto; não declara implementação ou activação operacional e preserva entregas e dados anteriores.
+10
View File
@@ -1,5 +1,13 @@
# SGF TEMPO
**Obrigações a fornecedores — ensaio:** SGF em http://127.0.0.1:8006 e Compras autónomo em http://127.0.0.1:8005/obrigacoes-fornecedores. [Guião, funções, código e provas](docs/SUPPLIER_PAYABLE_DELIVERY.md). Principal 11 600 MZN, vencimento pendente; sem pagamentos ou uso real. A demonstração orçamental em 8003 permanece independente.
**Percurso orçamental sintético disponível:** http://127.0.0.1:8003 → seleccionar empresa/unidade → **Compras → Controlo orçamental sintético**. [Contas por função, guião, arranque e provas](docs/SGF_BUDGET_WORKSPACE_DELIVERY.md). Interface ligada ao núcleo comum; base de equipa separada da demonstração anterior em 8002. Apenas QA, sem habilitação real ou aceitação visual presumida.
**Compras e núcleo financeiro comum:** [desenho de referência e transição](docs/PURCHASING_COMMON_CORE_PLAN.md). Um módulo, um núcleo financeiro e duas composições comerciais, sem facturação de vendas obrigatória no Compras autónomo. Primeiro incremento delimitado; reestruturação ainda não executada.
**Orientação actual de 29/10/2026:** desenvolvimento com organizações, funções e operações sintéticas; validação da equipa em entregas integradas; pessoas reais e concessões efectivas apenas na implantação. Configurações da Tempo preservadas. Consultar [demonstração integrada, funções e guião](docs/PRODUCT_DEMO.md). Esta decisão prevalece sobre dependências históricas de concessões reais nos parágrafos seguintes.
**Ensaio industrial (`INDUSTRIAL-COST-SIM-v1`):** lote/produto sintéticos com composição exacta, imputação por capacidade explícita, residual separado e variante incompleta. Percurso/provas e propostas pendentes em [INDUSTRIAL_COST_DELIVERY.md](docs/INDUSTRIAL_COST_DELIVERY.md). Exclusivamente `sgf_tempo_test`; `100000` e direitos industriais reais não aplicados. Custeio real e restantes operações reais continuam bloqueados; sem stock ou lançamentos.
**Actualização posterior de custos directos:** `090000` e os quatro direitos de consulta/preparação/validação foram instalados tecnicamente por autorização específica, com tabelas de custo vazias e custeio real bloqueado. Ver a secção de instalação em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md); as menções anteriores a essa pendência são históricas.
@@ -121,6 +129,8 @@ O primeiro núcleo de cálculo de serviço e a política operacional de truncage
O menu **Classificação contabilística**, disponível após actualizar a sessão com os acessos especificamente concedidos, permite preparar e submeter versões PGC-NIRF, validar/recusar sem editar e consultar classificação por data. Categoria e excepções são empresariais; operações e respectivos históricos continuam limitados às unidades autorizadas. As tabelas reais começam vazias, sem contas de movimento ou regras aprovadas por omissão. Consultar [utilização, provas e limites da classificação](docs/CATALOGUE_ACCOUNTING_DELIVERY.md).
## Ensaio isolado de custo directo
Correcção/cancelamento sintético de rascunhos ligados à oficina: [percurso, provas e limites](docs/WORKSHOP_BILLING_CORRECTION_DELIVERY.md). `130000` e cinco concessões apenas propostas; operações reais permanecem bloqueadas.
Ligação sintética oficina → prestação/rascunho rastreável: [âmbito implementado, provas e limites](docs/WORKSHOP_BILLING_DELIVERY.md). Migração `120000` e seis concessões instaladas tecnicamente por autorização específica de 28/10/2026; conversão real continua bloqueada. `110000` e sete direitos do orçamento foram instalados por autorização posterior separada, igualmente sem activação real.
Página inicial e menus agrupados: [alterações, verificações e limites visuais](docs/HOME_NAVIGATION_DELIVERY.md). Os atalhos dependem dos acessos existentes; não apresentam contagens de pendências ou indicadores financeiros simulados.
@@ -0,0 +1,19 @@
<?php
namespace App\Modules\Purchasing;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
final class BudgetController extends Controller
{
public function index(Request $request, BudgetWorkspace $workspace)
{
return response()->json($workspace->listing($request->user()))->header('Cache-Control', 'no-store, private');
}
public function command(Request $request, string $action, BudgetWorkspace $workspace)
{
return response()->json($workspace->command($request->user(), $action, $request->all(), (string) $request->header('Idempotency-Key')))->header('Cache-Control', 'no-store, private');
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Modules\Purchasing;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tempo\Finance\Budget\BudgetFailure;
/** Synthetic UI/application orchestration only. All financial effects delegate to BudgetControl. */
final class BudgetWorkspace
{
private function scope(User $user, string $right): array
{
BudgetAdapter::guard();
$context = app(CompanyContext::class);
abort_unless(auth()->id() === $user->id && $context->userId() === $user->id && $context->unitId() !== null, 403);
$s = $context->identifiers() + ['unit_id' => $context->unitId()];
$context->selectFor($user->id, $s['tenant_id'], $s['company_id'], $s['unit_id']);
foreach (array_unique(['view', $right]) as $permission) {
app(Permissions::class)->require($user->id, $s['tenant_id'], $s['company_id'], 'purchasing_budget.'.$permission, $s['unit_id']);
}
return $s;
}
private function input(array $data, array $rules): array
{
abort_if(array_diff(array_keys($data), array_keys($rules)) !== [], 422, 'Campo protegido ou fora do percurso sintético.');
return Validator::make($data, $rules)->validate();
}
private function money(string $value): int
{
if (! preg_match('/^(0|[1-9][0-9]{0,11})(\.[0-9]{1,2})?$/D', $value)) {
throw ValidationException::withMessages(['amount' => 'Use um montante MZN com ponto e até duas casas, sem arredondamento.']);
}
return BigDecimal::of($value)->multipliedBy(100)->toInt();
}
public function listing(User $user): array
{
$s = $this->scope($user, 'view');
$engine = app(BudgetAdapter::class)->for($user);
$budgets = DB::table('cb_budgets')->where($s)->orderBy('id')->get()->map(fn ($b) => (array) $b + ['balance' => $engine->balance($b->id)])->all();
$names = 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'])->pluck('u.name', 'u.id')->all();
return ['synthetic_only' => true, 'currency' => 'MZN', 'current_year' => $engine->context->year, 'budgets' => $budgets,
'centres' => DB::table('cb_centres')->where($s)->orderBy('id')->get(),
'origins' => DB::table('cb_origins')->where($s)->orderBy('id')->get(),
'entries' => DB::table('cb_budget_entries')->where($s)->orderBy('id')->get(),
'consumptions' => DB::table('cb_budget_consumptions')->where($s)->orderBy('id')->get(),
'consumption_sources' => DB::table('cb_consumption_sources')->where($s)->orderBy('id')->get(),
'reinforcements' => DB::table('cb_budget_reinforcements')->where($s)->orderBy('id')->get(['id', 'budget_id', 'created_by', 'reviewed_by', 'reference', 'amount_minor', 'reason', 'status', 'review_reason']),
'history' => DB::table('cb_audit')->where($s)->orderBy('id')->get(), 'actors' => $names];
}
public function command(User $user, string $action, array $input, string $key): array
{
$right = match ($action) {
'create' => 'create', 'approve' => 'approve', 'movement' => 'record', 'consume' => 'consume',
'reinforcement' => 'reinforcement.propose', 'review' => 'reinforcement.review', default => abort(404),
};
$s = $this->scope($user, $right);
abort_unless(Str::isUuid($key), 422, 'Chave de comando em falta.');
ksort($input);
$hash = hash('sha256', json_encode($input, JSON_THROW_ON_ERROR));
try {
return DB::transaction(function () use ($user, $action, $right, $input, $key, $hash, $s) {
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
$this->scope($user, $right);
$commands = DB::table('cb_ui_commands')->where($s)->where('actor_id', $user->id)->where('command_key', $key);
if ($old = $commands->first()) {
abort_unless($old->action === $action && hash_equals($old->request_hash, $hash), 409, 'Chave já usada com outro conteúdo.');
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
}
$adapter = app(BudgetAdapter::class);
$engine = $adapter->for($user);
$idRule = 'required|integer|min:1';
if ($action === 'create') {
$d = $this->input($input, ['cost_center_id' => $idRule, 'year' => 'required|integer|min:2026|max:2100', 'kind' => 'required|in:operating,investment', 'includes_tax' => 'required|boolean', 'amount' => 'required|string', 'reason' => 'required|string|min:3|max:4000']);
$d['amount_minor'] = $this->money($d['amount']);
unset($d['amount']);
$d['includes_tax'] = (bool) $d['includes_tax'];
$id = $adapter->create($user, $d);
} elseif ($action === 'approve') {
$d = $this->input($input, ['budget_id' => $idRule]);
$id = $d['budget_id'];
$engine->approve($id);
} elseif ($action === 'reinforcement') {
$d = $this->input($input, ['budget_id' => $idRule, 'amount' => 'required|string', 'reference' => 'required|string|max:100', 'reason' => 'required|string|min:3|max:4000']);
$id = $engine->proposeReinforcement($d['budget_id'], $this->money($d['amount']), Str::upper(Str::squish($d['reference'])), trim($d['reason']));
} elseif ($action === 'review') {
$d = $this->input($input, ['reinforcement_id' => $idRule, 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:3|max:4000']);
$id = $d['reinforcement_id'];
$engine->reviewReinforcement($id, $d['decision'], $d['reason']);
} else {
$rules = ['origin_id' => $idRule, 'total' => 'required|string', 'subtotal' => 'required|string'];
$rules += $action === 'movement' ? ['kind' => 'required|in:reserve,award,commit,release'] : ['reference' => 'required|string|min:3|max:100'];
$d = $this->input($input, $rules);
$origin = DB::table('cb_origins')->where($s)->where('id', $d['origin_id'])->first() ?? abort(403, 'Origem fora deste contexto.');
$total = $this->money($d['total']);
$net = $this->money($d['subtotal']);
abort_if($net > $total, 422, 'Subtotal não pode exceder o total indicado.');
$entries = DB::table('cb_budget_entries')->where($s)->where('requisition_id', $origin->id);
if ($action === 'movement') {
$kind = $d['kind'];
if (! (clone $entries)->where('kind', $kind)->exists()) {
// Procurement sequence, not budget arithmetic. No reopening or invented reversal.
$reserved = (clone $entries)->where('kind', 'reserve')->exists();
$closed = (clone $entries)->whereIn('kind', ['commit', 'release'])->exists();
abort_if($closed || ($kind !== 'reserve' && ! $reserved) || ($kind === 'commit' && ! (clone $entries)->where('kind', 'award')->exists()), 409, 'Sequência indisponível: reserve, ajuste a adjudicação e depois comprometa; libertação apenas antes do compromisso.');
}
$effect = $adapter->record($user, $origin->id, $kind, $total, $net);
abort_unless($effect, 409, 'Falta preparar e aprovar uma dotação para este centro/exercício.');
$id = $effect['budget_id'];
} else {
$reference = Str::upper(Str::squish($d['reference']));
$sources = DB::table('cb_consumption_sources')->where($s)->where('reference', $reference);
if ($source = $sources->first()) {
abort_unless((int) $source->origin_id === $origin->id && (int) $source->total_minor === $total && (int) $source->subtotal_minor === $net, 409, 'Referência sintética já usada com outros valores ou origem.');
$id = $source->id;
} else {
$id = DB::table('cb_consumption_sources')->insertGetId($s + ['origin_id' => $origin->id, 'reference' => $reference, 'total_minor' => $total, 'subtotal_minor' => $net, 'created_by' => $user->id, 'created_at' => now()]);
}
$engine->consume($origin->id, $id, $total, $net);
}
}
$result = ['id' => (int) $id, 'action' => $action];
DB::table('cb_ui_commands')->insert($s + ['actor_id' => $user->id, 'command_key' => $key, 'action' => $action, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
return $result + ['replayed' => false];
});
} catch (BudgetFailure $e) {
abort_if($e->status !== 422, $e->status, $e->getMessage());
throw ValidationException::withMessages([$e->field => $e->getMessage()]);
}
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Modules\Purchasing;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\Payables;
final class SupplierPayables
{
public static function guard(): void
{
$c = DB::connection();
abort_unless(app()->environment('payable-testing'), 403, 'Obrigações apenas no ensaio isolado.');
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) {
throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED');
}
}
public function scope(User $u): array
{
self::guard();
$c = app(CompanyContext::class);
abort_unless(auth()->id() === $u->id && $c->userId() === $u->id && $c->unitId(), 403);
$ids = $c->identifiers();
app(Permissions::class)->require($u->id, $ids['tenant_id'], $ids['company_id'], 'supplier_payables.view', $c->unitId());
return $c->identifiers() + ['unit_id' => $c->unitId()];
}
public function can(User $u, string $a): bool
{
return DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $a])->exists();
}
public function document(User $u, int $id): array
{
$row = DB::table('pa_imported_sources')->where($this->scope($u))->where('id', $id)->first() ?? abort(403, 'Origem não mapeada neste contexto.');
$s = json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR);
abort_unless(hash_equals($row->receipt_hash, hash('sha256', Payables::canonical($s))), 409, 'Recibo de origem divergente.');
$s['local_id'] = $id;
return $s;
}
public function engine(User $u): Payables
{
$scope = $this->scope($u);
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
abort_unless($person, 403);
return new Payables(DB::connection(), $u->id, $person, $scope, function (string $a) use ($u, $scope, $person) {
self::guard();
DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
DB::table('users')->where('id', $u->id)->lockForUpdate()->firstOrFail();
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409, 'Identidade de pessoa alterada; reabra a proposta.');
abort_unless($this->scope($u) === $scope, 409, 'Contexto mudou.');
app(CompanyContext::class)->selectFor($u->id, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
abort_unless($this->can($u, 'view') && $this->can($u, $a), 403);
}, fn (int $id) => $this->document($u, $id));
}
public function listing(User $u): array
{
$d = $this->engine($u)->listing(DB::table('pa_imported_sources')->where($this->scope($u))->pluck('id')->all());
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
return $d;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Modules\Purchasing;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\Workspace;
final class SupplierPayablesController extends Controller
{
public function index(Request $r, SupplierPayables $s)
{
return response()->json($s->listing($r->user()))->header('Cache-Control', 'no-store');
}
public function page(Request $r, SupplierPayables $s)
{
$data = $s->listing($r->user());
$scope = $s->scope($r->user());
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
return response(Workspace::render($data, ['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
}
public function command(Request $r, string $action, SupplierPayables $s)
{
$d = $r->validate(['id' => 'required|integer|min:1', 'reason' => 'required|string|min:3|max:2000']);
abort_if(array_diff(array_keys($r->all()), ['id', 'reason']) !== [], 422);
try {
return response()->json($s->engine($r->user())->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
} catch (\DomainException $e) {
abort(409, $e->getMessage());
} catch (\InvalidArgumentException $e) {
abort(422, $e->getMessage());
}
}
}
@@ -93,6 +93,7 @@ final class DocumentPreparation
$data = array_intersect_key($input, array_flip($fields));
if ($scope['unit_id']) {
$this->workflow->scope($kind, $actor, 'view');
\App\Modules\Workshop\WorkshopCommercialState::assertCurrent($kind, $id);
abort_if($kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'A fotografia e o rascunho B1 estão bloqueados.');
abort_unless((int) $target->version === $input['draft_version'], 409, 'O rascunho mudou; reveja a preparação.');
$draftData = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
@@ -37,6 +37,7 @@ final class InternalConfirmation
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$this->workflow->scope('billing_drafts', $actor, 'view');
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']);
\App\Modules\Workshop\WorkshopCommercialState::assertCurrent('billing_drafts', $draft);
$old = DB::table('a_commands')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('command_key', $key)->first();
if ($old) {
abort_unless((int) $old->actor_id === $actor && hash_equals($old->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.');
+5 -2
View File
@@ -55,6 +55,9 @@ final class ServiceWorkflow
$result = (array) $row;
$result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
$result['version'] = (int) $row->version;
if (in_array($area, ['service_drafts','billing_drafts'], true)) {
$result['workshop_commercial_state'] = \App\Modules\Workshop\WorkshopCommercialState::state($area, (int)$row->id);
}
if ($area === 'billing_drafts') {
$confirmationId = DB::table('b_internal_confirmations')->where('billing_draft_id', $row->id)->value('id');
$result['internal_confirmation'] = $confirmationId ? ['reference' => 'INT-B1-'.$confirmationId, 'label' => InternalConfirmation::LABEL] : null;
@@ -86,7 +89,7 @@ final class ServiceWorkflow
abort_unless($old, 403, 'Registo fora do âmbito autorizado.');
if (in_array($area, ['service_drafts','billing_drafts'], true) && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
$column = $area === 'service_drafts' ? 'service_draft_id' : 'billing_draft_id';
abort_if(DB::table('wb_generations')->where($column, $id)->exists(),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.');
abort_if(\App\Modules\Workshop\WorkshopCommercialState::linked($area, $id),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.');
}
abort_if($area === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'Confirmado internamente — sem validade fiscal. O registo está bloqueado para edição.');
abort_unless((int) $old->version === (int) ($input['version'] ?? 0), 409, 'O registo mudou. Reabra-o antes de guardar.');
@@ -142,7 +145,7 @@ final class ServiceWorkflow
if ($area === 'billing_drafts') {
$sourceId = $old ? $old->source_id : $input['source_id'];
if (!$old && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
abort_if(DB::table('wb_generations')->where('service_draft_id', $sourceId)->exists(),409,'Prestação de oficina já convertida: não pode gerar um segundo rascunho por outro percurso.');
abort_if(\App\Modules\Workshop\WorkshopCommercialState::linked('service_drafts', (int)$sourceId),409,'Prestação de oficina já convertida: não pode gerar um segundo rascunho por outro percurso.');
}
$source = DB::table('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first();
abort_unless($source, 403, 'Prestação fora da unidade autorizada.');
@@ -0,0 +1,12 @@
<?php
namespace App\Modules\Workshop\Http;
use App\Http\Controllers\Controller;
use App\Modules\Workshop\WorkshopCorrections;
use Illuminate\Http\Request;
final class WorkshopCorrectionsController extends Controller {
private function response(array $d){return response()->json($d)->header('Cache-Control','no-store, private');}
public function index(Request $r,WorkshopCorrections $c){return $this->response($c->listing($r->user()->id));}
public function request(Request $r,int $id,WorkshopCorrections $c){return $this->response($c->request($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
public function review(Request $r,int $id,WorkshopCorrections $c){return $this->response($c->review($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
public function apply(Request $r,int $id,WorkshopCorrections $c){return $this->response($c->apply($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
}
+7 -1
View File
@@ -76,10 +76,16 @@ final class WorkshopBilling {
$billing=$this->workflow->save('billing_drafts',null,$actor,['source_id'=>$service['id'],'name'=>$name],substr(hash('sha256','wb-billing-'.$key),0,40));
$id=DB::table('wb_generations')->insertGetId(['order_id'=>$order,'proposal_id'=>$p->id,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'actor_id'=>$actor,'created_at'=>now()]);foreach($snap['lines'] as $l)DB::table('wb_claims')->insert(['generation_id'=>$id,'order_id'=>$order,'origin'=>$l['origin'],'entry_id'=>$l['entry_id'],'entry_version'=>$l['entry_version']]);return ['id'=>$id,'version'=>$p->version,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'base_exact'=>$snap['base_exact']];});
}
private function generationState(int $order): ?object {
$g=DB::table('wb_generations')->where('order_id',$order)->first();if(!$g)return null;
$g->commercial_state=WorkshopCommercialState::state('billing_drafts',$g->billing_draft_id);
$g->latest_commercial_change=\Illuminate\Support\Facades\Schema::hasTable('wc_applications')?DB::table('wc_applications')->where('generation_id',$g->id)->orderByDesc('sequence')->first():null;
return $g;
}
public function listing(int $actor): array {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[]];self::guard();
return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$orders=[];foreach(DB::table('wo_orders')->where($s)->get() as $o){$entries=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->get() as $e){try{$entries[]=$this->source($e->id,$e->version,$o->id);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}}
$proposals=DB::table('wb_proposals')->where('order_id',$o->id)->orderBy('version')->get()->all();foreach($proposals as $p){$p->snapshot=json_decode($p->snapshot,true);$p->decision=DB::table('wb_decisions')->where('proposal_id',$p->id)->first();$p->current=!DB::table('wb_proposals')->where('previous_id',$p->id)->exists();if($p->current){try{$this->recheck($p,$s,true);}catch(\Throwable){$p->current=false;}}}
$orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>DB::table('wb_generations')->where('order_id',$o->id)->first()];}return ['blocked'=>false,'orders'=>$orders,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
$orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Modules\Workshop;
use Illuminate\Support\Facades\{DB,Schema};
final class WorkshopCommercialState {
public static function linked(string $area,int $id): bool {
if(!Schema::hasTable('wb_generations'))return false;$column=$area==='service_drafts'?'service_draft_id':'billing_draft_id';
if(DB::table('wb_generations')->where($column,$id)->exists())return true;
if(!Schema::hasTable('wc_applications'))return false;
return DB::table('wc_applications')->where($area==='service_drafts'?'new_service_id':'new_billing_id',$id)->exists();
}
public static function state(string $area,int $id): ?string {
if(!self::linked($area,$id))return null;if(!Schema::hasTable('wc_applications'))return 'current';
$event=DB::table('wc_applications')->where($area==='service_drafts'?'old_service_id':'old_billing_id',$id)->first();
return $event?($event->kind==='replace'?'superseded':'cancelled'):'current';
}
public static function assertCurrent(string $area,int $id): void {
$state=self::state($area,$id);abort_if($state!==null&&$state!=='current',409,'Preparação comercial substituída ou cancelada; apenas consulta histórica.');
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Modules\Workshop;
use App\Modules\Identity\Services\{Permissions,Audit};
use App\Modules\Services\{ServiceWorkflow,DraftValues,ServiceCalculation};
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\{DB,Validator};
final class WorkshopCorrections {
public const POLICY='WORKSHOP-BILLING-CORRECTION-SIM-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions,private ServiceWorkflow $workflow){}
public static function guard(): void {WorkshopBilling::guard();}
private function json($v): string{return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
private function canonical($v){if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
private function exact(array $d,array $rules): array{abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo fora da correcção comercial.');return Validator::make($d,$rules,['required'=>'Preencha :attribute.','present'=>'Indique :attribute.'])->validate();}
private function scope(int $actor,string $right): array {
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($this->context->userId()===$actor&&$s['unit_id']!==null,403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach(['workshop_corrections.view','workshop_corrections.'.$right,'workshop_billing.view','workshop.view'] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);
foreach(['clients.view','services.view'] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p);return $s;
}
private function root(int $id,array $s): object {
$g=DB::table('wb_generations as g')->join('wo_orders as o','o.id','=','g.order_id')->where('g.id',$id)->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->select('g.*')->first();return $g??abort(403,'Geração fora do contexto.');
}
private function current(object $g): array {
$a=DB::table('wc_applications')->where('generation_id',$g->id)->orderByDesc('sequence')->first();abort_if($a?->kind==='cancel',409,'Preparação cancelada; origens não reabertas.');
return ['sequence'=>$a?->sequence??0,'service_id'=>$a?->new_service_id??$g->service_draft_id,'billing_id'=>$a?->new_billing_id??$g->billing_draft_id];
}
private function baseline(object $g,array $s): array {
$c=$this->current($g);$service=DB::table('a_service_drafts')->find($c['service_id']);$bill=DB::table('a_billing_drafts')->find($c['billing_id']);$order=DB::table('wo_orders')->find($g->order_id);
abort_unless($service&&$bill&&$service->state==='draft'&&$bill->state==='draft'&&$bill->source_id===$service->id&&$bill->client_id===$order->client_id&&$service->client_id===$order->client_id,409,'Destino ou cliente incompatível.');
WorkshopCommercialState::assertCurrent('billing_drafts',$bill->id);
abort_if(DB::table('b_internal_confirmations')->where('billing_draft_id',$bill->id)->exists()||DB::table('a_billing_drafts')->where('source_id',$service->id)->where('id','<>',$bill->id)->exists(),409,'Destino confirmado ou com dependências.');
// Conservative pilot: prepared document payloads require a separately reviewed inheritance path.
abort_if(DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->where(fn($q)=>$q->where(fn($x)=>$x->where('kind','billing_drafts')->where('record_id',$bill->id))->orWhere(fn($x)=>$x->where('kind','service_drafts')->where('record_id',$service->id)))->exists(),409,'Preparação documental existente: herança exige revisão própria.');
$origin=json_decode(DB::table('wb_proposals')->find($g->proposal_id)->snapshot,true,64,JSON_THROW_ON_ERROR);
foreach($origin['lines'] as $line){$e=DB::table('wo_entries')->where(['id'=>$line['entry_id'],'version'=>$line['entry_version'],'order_id'=>$g->order_id])->first();abort_unless($e&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$e->id)->exists()&&!DB::table('wo_entries')->where('previous_id',$e->task_id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$e->id,'entry_version'=>$e->version,'decision'=>'verified'])->exists(),409,'Origem técnica desactualizada ou não verificada.');}
$bd=json_decode($bill->data,true);abort_unless($bd['currency']==='MZN',409,'Moeda incompatível.');
return $c+['service_version'=>$service->version,'billing_version'=>$bill->version,'client_id'=>$bill->client_id,'client_version'=>DB::table('a_clients')->find($bill->client_id)->version,'currency'=>'MZN','current_base_exact'=>DraftValues::base($bd),'original_origins'=>$origin['lines'],'current_lines'=>$bd['lines'],'doc_versions'=>DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->get(['id','version'])->map(fn($r)=>(array)$r)->all()];
}
private function commercial(array $base,array $input,array $s): array {
abort_unless(count($input)===count($base['original_origins']),422,'Manter todas as origens integrais.');$out=[];
foreach($base['original_origins'] as $i=>$origin){$l=$this->exact($input[$i],['entry_id'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120','description'=>'required|string|min:3|max:500']);
abort_unless($l['entry_id']===$origin['entry_id'],409,'Não pode substituir ou reordenar origens.');
$service=DB::table('a_services')->where(['id'=>$l['service_id'],'version'=>$l['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$price=DraftValues::decimal($l['price'],'preço comercial');abort_if($price===null,422,'Preço desconhecido.');
$out[]=array_replace($origin,['service_id'=>$service->id,'service_version'=>$service->version,'unit_price'=>$price,'price_source'=>$l['price_source'],'description'=>$l['description']]);
}return $out;
}
private function cmd(int $generation,int $actor,string $action,array $in,string $key,callable $write): array {
self::guard();$s=$this->scope($actor,$action);$this->root($generation,$s);abort_unless(preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422);$hash=hash('sha256',$this->json($this->canonical($in)));
return DB::transaction(function()use($generation,$actor,$action,$in,$key,$write,$s,$hash){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$action);$g=$this->root($generation,$s);
if($action==='apply'){foreach(['service_drafts','billing_drafts'] as $area)$this->workflow->scope($area,$actor,'create');}
$old=DB::table('wc_commands')->where(['generation_id'=>$generation,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave com conteúdo diferente.');return json_decode($old->result,true)+['replayed'=>true];}
$result=$write($g,$s);$command=DB::table('wc_commands')->insertGetId(['generation_id'=>$generation,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($result),'created_at'=>now()]);
Audit::record('workshop_corrections.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$result['id'],'version'=>$result['version']]);$event=DB::table('wc_events')->insertGetId(['command_id'=>$command,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'action'=>$action,'result'=>$result]),'created_at'=>now()]);DB::table('wc_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $result+['replayed'=>false];},3);
}
public function request(int $generation,int $actor,array $in,string $key): array {
self::guard();$d=$this->exact($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','kind'=>'required|in:replace,cancel,withdraw','billing_id'=>'required|integer|min:1','billing_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','lines'=>'present|array','reason'=>'required|string|min:3|max:1000']);
return $this->cmd($generation,$actor,'request',$d,$key,function($g,$s)use($d,$actor){$base=$this->baseline($g,$s);foreach(['billing_id','billing_version','service_id','service_version'] as $field)abort_unless($d[$field]===$base[$field],409,'Destino mudou; rever versões.');$last=DB::table('wc_requests')->where('generation_id',$g->id)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&$d['previous_id']===$last?->id,409,'Pedido anterior mudou.');
if($d['kind']==='withdraw')abort_unless($last&&!DB::table('wc_applications')->where('request_id',$last->id)->exists(),409,'Só desistir de pedido não aplicado.');
if($d['kind']!=='replace')abort_unless($d['lines']===[],422,'Cancelamento/desistência não têm novas linhas.');$lines=$d['kind']==='replace'?$this->commercial($base,$d['lines'],$s):[];
$snapshot=['policy'=>self::POLICY,'baseline'=>$base,'lines'=>$lines,'base_exact'=>$lines?DraftValues::base(['lines'=>$lines]):null,'reason'=>$d['reason']];$id=DB::table('wc_requests')->insertGetId(['generation_id'=>$g->id,'version'=>$d['version'],'previous_id'=>$d['previous_id'],'kind'=>$d['kind'],'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version']];});
}
private function selected(int $generation,array $d): object {$r=DB::table('wc_requests')->where(['id'=>$d['id'],'version'=>$d['version'],'generation_id'=>$generation])->first()??abort(409,'Pedido/versão incompatível.');abort_if($r->kind==='withdraw'||DB::table('wc_requests')->where('previous_id',$r->id)->exists(),409,'Pedido desistido ou desactualizado.');return $r;}
private function check(object $r,object $g,array $s): array {$snap=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);$base=$this->baseline($g,$s);abort_unless($this->canonical($snap['baseline'])===$this->canonical($base),409,'Origem, destino ou dependência mudou; nova revisão necessária.');if($r->kind==='replace'){$input=array_map(fn($l)=>['entry_id'=>$l['entry_id'],'service_id'=>$l['service_id'],'service_version'=>$l['service_version'],'price'=>$l['unit_price'],'price_source'=>$l['price_source'],'description'=>$l['description']],$snap['lines']);$this->commercial($base,$input,$s);}return $snap;}
public function review(int $generation,int $actor,array $in,string $key): array {
self::guard();$d=$this->exact($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);return $this->cmd($generation,$actor,'review',$d,$key,function($g,$s)use($d,$actor){$r=$this->selected($g->id,$d);abort_if($r->actor_id===$actor,403,'Revisor distinto necessário.');$this->check($r,$g,$s);abort_if(DB::table('wc_reviews')->where('request_id',$r->id)->exists(),409,'Decisão imutável já registada.');$id=DB::table('wc_reviews')->insertGetId(['request_id'=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version];});
}
public function apply(int $generation,int $actor,array $in,string $key): array {
self::guard();$d=$this->exact($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1']);return $this->cmd($generation,$actor,'apply',$d,$key,function($g,$s)use($d,$actor,$key){$r=$this->selected($g->id,$d);abort_unless(DB::table('wc_reviews')->where(['request_id'=>$r->id,'version'=>$r->version,'decision'=>'approved'])->exists(),409,'Revisão exacta necessária.');$snap=$this->check($r,$g,$s);$base=$snap['baseline'];$service=null;$bill=null;
if($r->kind==='replace'){$lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['service_id','quantity','unit_price','description'])),$snap['lines']);$name='Correcção oficina #'.$g->order_id.' · pedido #'.$r->id.' v'.$r->version;
$service=$this->workflow->save('service_drafts',null,$actor,['name'=>$name,'client_id'=>$base['client_id'],'currency'=>'MZN','lines'=>$lines,'description'=>'Substituição rastreável; original preservado.'],substr(hash('sha256','wc-service-'.$g->id.'-'.$r->id.'-'.$key),0,40))['id'];
$bill=$this->workflow->save('billing_drafts',null,$actor,['name'=>$name,'source_id'=>$service],substr(hash('sha256','wc-billing-'.$g->id.'-'.$r->id.'-'.$key),0,40))['id'];
}
$id=DB::table('wc_applications')->insertGetId(['generation_id'=>$g->id,'request_id'=>$r->id,'sequence'=>$base['sequence']+1,'kind'=>$r->kind,'old_service_id'=>$base['service_id'],'old_billing_id'=>$base['billing_id'],'new_service_id'=>$service,'new_billing_id'=>$bill,'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>$r->kind,'service_draft_id'=>$service,'billing_draft_id'=>$bill,'base_exact'=>$snap['base_exact']];});
}
public function listing(int $actor): array {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'generations'=>[]];self::guard();return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$generations=[];
foreach(DB::table('wb_generations as g')->join('wo_orders as o','o.id','=','g.order_id')->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->select('g.*')->get() as $g){$current=null;$issue=null;try{$current=$this->baseline($g,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$issue=$e->getMessage();}$requests=DB::table('wc_requests')->where('generation_id',$g->id)->orderBy('version')->get()->all();foreach($requests as $r){$r->snapshot=json_decode($r->snapshot,true);$r->review=DB::table('wc_reviews')->where('request_id',$r->id)->first();$r->applied=DB::table('wc_applications')->where('request_id',$r->id)->first();$r->current=!DB::table('wc_requests')->where('previous_id',$r->id)->exists()&&$current!==null&&$this->canonical($r->snapshot['baseline'])===$this->canonical($current);}
$generations[]=(array)$g+['current'=>$current,'issue'=>$issue,'requests'=>$requests,'applications'=>DB::table('wc_applications')->where('generation_id',$g->id)->orderBy('sequence')->get()->all()];}
return ['blocked'=>false,'generations'=>$generations,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
}
}
+1
View File
@@ -16,6 +16,7 @@ class AppServiceProvider extends ServiceProvider
public function register(): void
{
require_once base_path('packages/budget-control/bootstrap.php');
require_once base_path('packages/payables/bootstrap.php');
$this->app->scoped(CompanyContext::class, fn () => new CompanyContext());
}
@@ -0,0 +1,16 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void {
$old=substr(DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d,6);
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 (($old) OR (permission IN ('workshop_corrections.view','workshop_corrections.request','workshop_corrections.review','workshop_corrections.apply') AND unit_id IS NOT NULL))");
DB::unprepared(file_get_contents(__DIR__.'/../sql/workshop-corrections.sql'));
foreach(['wc_requests','wc_reviews','wc_applications','wc_commands','wc_events','wc_outbox'] as $t){
DB::statement("CREATE TRIGGER {$t}_immutable BEFORE UPDATE OR DELETE ON {$t} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
DB::statement("CREATE TRIGGER {$t}_environment BEFORE INSERT ON {$t} FOR EACH ROW EXECUTE FUNCTION sgf_ic_test_only()");
}
}
public function down(): void {throw new RuntimeException('Correction history requires reviewed preservation.');}
};
+49
View File
@@ -0,0 +1,49 @@
CREATE TABLE wc_requests(id bigserial PRIMARY KEY,generation_id bigint NOT NULL REFERENCES wb_generations(id),version integer NOT NULL CHECK(version>0),previous_id bigint UNIQUE REFERENCES wc_requests(id),kind text NOT NULL CHECK(kind IN ('replace','cancel','withdraw')),snapshot jsonb NOT NULL,actor_id bigint NOT NULL REFERENCES users(id),created_at timestamptz NOT NULL,UNIQUE(generation_id,version));
CREATE TABLE wc_reviews(id bigserial PRIMARY KEY,request_id bigint UNIQUE NOT NULL REFERENCES wc_requests(id),version integer NOT NULL,decision text NOT NULL CHECK(decision IN ('approved','refused')),reason text NOT NULL CHECK(length(reason)>=3),actor_id bigint NOT NULL REFERENCES users(id),created_at timestamptz NOT NULL);
CREATE TABLE wc_applications(id bigserial PRIMARY KEY,generation_id bigint NOT NULL REFERENCES wb_generations(id),request_id bigint UNIQUE NOT NULL REFERENCES wc_requests(id),sequence integer NOT NULL CHECK(sequence>0),kind text NOT NULL CHECK(kind IN ('replace','cancel')),old_service_id bigint UNIQUE NOT NULL REFERENCES a_service_drafts(id),old_billing_id bigint UNIQUE NOT NULL REFERENCES a_billing_drafts(id),new_service_id bigint UNIQUE REFERENCES a_service_drafts(id),new_billing_id bigint UNIQUE REFERENCES a_billing_drafts(id),actor_id bigint NOT NULL REFERENCES users(id),created_at timestamptz NOT NULL,UNIQUE(generation_id,sequence),CHECK((kind='replace' AND new_service_id IS NOT NULL AND new_billing_id IS NOT NULL) OR (kind='cancel' AND new_service_id IS NULL AND new_billing_id IS NULL)));
CREATE TABLE wc_commands(id bigserial PRIMARY KEY,generation_id bigint NOT NULL REFERENCES wb_generations(id),actor_id bigint NOT NULL REFERENCES users(id),action text NOT NULL CHECK(action IN ('request','review','apply')),command_key text NOT NULL,request_hash text NOT NULL,result jsonb NOT NULL,created_at timestamptz NOT NULL,UNIQUE(generation_id,actor_id,action,command_key));
CREATE TABLE wc_events(id bigserial PRIMARY KEY,command_id bigint UNIQUE NOT NULL REFERENCES wc_commands(id),payload jsonb NOT NULL,created_at timestamptz NOT NULL);
CREATE TABLE wc_outbox(event_id bigint PRIMARY KEY REFERENCES wc_events(id),state text NOT NULL CHECK(state='captured_no_stock_or_accounting'));
CREATE OR REPLACE FUNCTION sgf_wc_linked_service(sid bigint) RETURNS boolean LANGUAGE sql AS $$ SELECT EXISTS(SELECT 1 FROM wb_generations WHERE service_draft_id=sid) OR EXISTS(SELECT 1 FROM wc_applications WHERE new_service_id=sid) $$;
CREATE OR REPLACE FUNCTION sgf_wc_linked_billing(bid bigint) RETURNS boolean LANGUAGE sql AS $$ SELECT EXISTS(SELECT 1 FROM wb_generations WHERE billing_draft_id=bid) OR EXISTS(SELECT 1 FROM wc_applications WHERE new_billing_id=bid) $$;
CREATE OR REPLACE FUNCTION sgf_wc_assert_current(bid bigint) RETURNS void LANGUAGE plpgsql AS $$
BEGIN IF EXISTS(SELECT 1 FROM wc_applications WHERE old_billing_id=bid) THEN RAISE EXCEPTION 'SGF_WC_DESTINATION_SUPERSEDED_OR_CANCELLED'; END IF; END; $$;
CREATE OR REPLACE FUNCTION sgf_wc_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r wc_requests; g wb_generations; a wc_applications; n integer; prior bigint; sid bigint; bid bigint;
BEGIN
PERFORM sgf_ic_environment();
IF TG_TABLE_NAME='wc_reviews' THEN SELECT * INTO STRICT r FROM wc_requests WHERE id=NEW.request_id; SELECT * INTO STRICT g FROM wb_generations WHERE id=r.generation_id;
ELSE SELECT * INTO STRICT g FROM wb_generations WHERE id=NEW.generation_id; END IF;
PERFORM 1 FROM companies WHERE id=(SELECT company_id FROM wo_orders WHERE id=g.order_id) FOR UPDATE;
IF TG_TABLE_NAME='wc_requests' THEN
SELECT coalesce(max(version),0) INTO n FROM wc_requests WHERE generation_id=g.id; SELECT id INTO prior FROM wc_requests WHERE generation_id=g.id AND version=n;
IF NEW.version<>n+1 OR NEW.previous_id IS DISTINCT FROM prior THEN RAISE EXCEPTION 'SGF_WC_REQUEST_VERSION'; END IF;
ELSIF TG_TABLE_NAME='wc_reviews' THEN
IF r.actor_id=NEW.actor_id OR r.version<>NEW.version OR r.kind='withdraw' OR EXISTS(SELECT 1 FROM wc_requests WHERE previous_id=r.id) THEN RAISE EXCEPTION 'SGF_WC_REVIEW_IDENTITY_VERSION'; END IF;
ELSIF TG_TABLE_NAME='wc_applications' THEN
SELECT * INTO STRICT r FROM wc_requests WHERE id=NEW.request_id AND generation_id=g.id;
IF r.kind<>NEW.kind OR EXISTS(SELECT 1 FROM wc_requests WHERE previous_id=r.id) OR NOT EXISTS(SELECT 1 FROM wc_reviews WHERE request_id=r.id AND version=r.version AND decision='approved') THEN RAISE EXCEPTION 'SGF_WC_APPROVED_REQUEST'; END IF;
SELECT * INTO a FROM wc_applications WHERE generation_id=g.id ORDER BY sequence DESC LIMIT 1;
sid=coalesce(a.new_service_id,g.service_draft_id);bid=coalesce(a.new_billing_id,g.billing_draft_id);
IF a.kind='cancel' OR NEW.sequence<>coalesce(a.sequence,0)+1 OR NEW.old_service_id<>sid OR NEW.old_billing_id<>bid THEN RAISE EXCEPTION 'SGF_WC_DESTINATION_CHAIN'; END IF;
IF EXISTS(SELECT 1 FROM b_internal_confirmations WHERE billing_draft_id=bid) OR EXISTS(SELECT 1 FROM a_billing_drafts WHERE source_id=sid AND id<>bid) THEN RAISE EXCEPTION 'SGF_WC_DEPENDENCY'; END IF;
IF NEW.kind='replace' AND NOT EXISTS(SELECT 1 FROM a_billing_drafts b JOIN a_service_drafts s ON s.id=b.source_id JOIN wo_orders o ON o.id=g.order_id WHERE b.id=NEW.new_billing_id AND s.id=NEW.new_service_id AND b.state='draft' AND s.state='draft' AND ROW(b.tenant_id,b.company_id,b.unit_id,b.client_id)=ROW(o.tenant_id,o.company_id,o.unit_id,o.client_id) AND ROW(s.tenant_id,s.company_id,s.unit_id,s.client_id)=ROW(o.tenant_id,o.company_id,o.unit_id,o.client_id)) THEN RAISE EXCEPTION 'SGF_WC_NEW_DESTINATION_SCOPE'; END IF;
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER wc_request_integrity BEFORE INSERT ON wc_requests FOR EACH ROW EXECUTE FUNCTION sgf_wc_integrity();
CREATE TRIGGER wc_review_integrity BEFORE INSERT ON wc_reviews FOR EACH ROW EXECUTE FUNCTION sgf_wc_integrity();
CREATE TRIGGER wc_application_integrity BEFORE INSERT ON wc_applications FOR EACH ROW EXECUTE FUNCTION sgf_wc_integrity();
CREATE OR REPLACE FUNCTION sgf_wb_draft_locked() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF (TG_TABLE_NAME='a_service_drafts' AND sgf_wc_linked_service(OLD.id)) OR (TG_TABLE_NAME='a_billing_drafts' AND sgf_wc_linked_billing(OLD.id)) THEN RAISE EXCEPTION 'SGF_WB_LINKED_DRAFT_REQUIRES_REVIEWED_CORRECTION'; END IF;
IF TG_OP='UPDATE' THEN RETURN NEW; END IF; RETURN OLD;
END; $$;
CREATE OR REPLACE FUNCTION sgf_wb_no_second_billing() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF sgf_wc_linked_service(NEW.source_id) THEN RAISE EXCEPTION 'SGF_WB_SOURCE_ALREADY_CONVERTED'; END IF; RETURN NEW; END; $$;
CREATE OR REPLACE FUNCTION sgf_wc_confirmation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM 1 FROM companies WHERE id=NEW.company_id FOR UPDATE;
PERFORM sgf_wc_assert_current(NEW.billing_draft_id); RETURN NEW;
END; $$;
CREATE TRIGGER wc_confirmation_current BEFORE INSERT ON b_internal_confirmations FOR EACH ROW EXECUTE FUNCTION sgf_wc_confirmation_guard();
+73
View File
@@ -0,0 +1,73 @@
# Demonstração integrada do produto SGF
**Percurso orçamental acrescentado em 29/10/2026:** [interface SGF, contas por função e guião](SGF_BUDGET_WORKSPACE_DELIVERY.md), disponível em 8003 com base persistente separada. Reutiliza o núcleo aceite; a demonstração anterior em 8002 e os seus dados mantêm-se. A interface deixa de estar apenas no adaptador, mas a revisão visual da equipa continua pendente.
**Orientação posterior sobre Compras:** [núcleo comum e duas composições comerciais](PURCHASING_COMMON_CORE_PLAN.md). Preservar esta demonstração e os seus dados; os ensaios destrutivos da futura extracção terão ambientes sintéticos separados. O plano delimita controlo orçamental comum, sem declarar Compras já incorporado ou exigir Sales no produto autónomo.
Decisão expressa da Tempo, 29/10/2026: separar desenvolvimento sintético, validação pela equipa e implantação no cliente. Pessoas reais e concessões na Tempo deixam de ser dependências de desenvolvimento. Aprovações técnicas passam a ser agrupadas em entregas funcionais. Configurações já realizadas na Tempo são preservadas como configurações desse cliente.
## Inventário anterior e âmbito desta consolidação
Os relatórios existentes continuam a ser a evidência de cada entrega. Não se declarou perda de módulos nem se repetiu a regressão aceite. O delta desta entrega é a consolidação de exemplos no mesmo contexto sintético, a retoma persistente e este guião. A prova da correcção de oficina tinha servidor/manifesto próprios; os testes automáticos partilham a base de teste e não devem destruir os exemplos enquanto a equipa trabalha.
| Percurso existente | Referência conservada | Papel na demonstração |
| --- | --- | --- |
| Entrada, contexto, permissões | IDENTITY_DELIVERY.md | Empresa Horizonte, unidade central; outras empresas/unidades para isolamento |
| Clientes, serviços, prestações, rascunhos, B1 | SERVICES_A_DELIVERY.md; SERVICES_B1_DELIVERY.md | Preparação e confirmação interna, com fotografia e histórico |
| Documentação e pré-visualização | DOCUMENT_PREPARATION_DELIVERY.md; DRAFT_INVOICE_PREVIEW_DELIVERY.md | Reutilizar cadastro e conferir pendências |
| Cálculo e facturas de teste/PDF | SERVICE_INVOICE_TEST_DELIVERY.md | Documento de exemplo de 1 000 MZN, hipótese sintética; sem validade fiscal |
| Recebimentos simulados | SERVICE_RECEIVABLES_DELIVERY.md | Preparar obrigação e recebimentos parciais, confirmar distintamente, afectar e conferir |
| Oficina e custo directo | WORKSHOP_RESOURCE_DELIVERY.md; WORKSHOP_DIRECT_COST_DELIVERY.md | Ordem/apontamentos verificados; preparar/validar custo de 275 MZN |
| Oficina → rascunho e correcção | WORKSHOP_BILLING_DELIVERY.md; WORKSHOP_BILLING_CORRECTION_DELIVERY.md | Original 500 MZN; experimentar substituição 510 e cancelamento |
| Custo industrial e orçamento | INDUSTRIAL_COST_DELIVERY.md; INDUSTRIAL_BUDGET_DELIVERY.md | Resultado de 1 800 MZN; preparar orçamento e comparar desvios |
| Classificação e fiscalidade transversal | CATALOGUE_ACCOUNTING_DELIVERY.md; SERVICE_INVOICE_CALCULATION_SPEC.md §§10–11 | Capacidades e lacunas conservadas; consolidação não declara modelos fiscais concluídos |
Os exemplos de oficina, indústria e serviço facturado são cenários distintos na mesma empresa/unidade. A oficina ainda não está ligada à emissão fiscal ou a lançamentos; não apresentar a justaposição como integração contabilística completa. Compras, stocks, contabilidade geral, fecho, modelos e demais ligações em falta continuam trabalho de produto, autorizável no contexto sintético sem configurar pessoas reais. Reutilizar o conhecimento fiscal do InvesteMoz conforme os requisitos FIS-SGF existentes, sem refazer investigação aceite.
## Preparar e retomar
Escolha técnica: reutilizar a base isolada já protegida `sgf_tempo_test`/`sgf_test_runner`, loopback 55432, e o servidor de demonstração em loopback 8002. Não alterar `sgf_tempo_dev`, contas, concessões ou configurações da Tempo. Não executar migrações nem resets para preparar esta consolidação. Dados existentes sem manifesto reconhecido provocam recusa explícita, sem apagar nada.
```powershell
# Conferir a consolidação transaccional sem guardar os novos exemplos:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/product-demo.php --env=testing --verify
# Consolidar exemplos no manifesto sintético existente:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/product-demo.php --env=testing
# Retomar o servidor quando estiver parado:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 start -Port 8002 -ProductDemo
# Parar apenas o servidor próprio; conservar manifesto e exemplos:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 stop
```
A base/cluster precisa de estar disponível. A execução repetida da consolidação reconhece o manifesto e não volta a criar exemplos ou concessões. `--verify` ensaia com rollback antes da primeira preparação; depois consulta o manifesto existente. `sgf.ps1 test` recusa testes destrutivos enquanto existir o manifesto integrado, mesmo com o servidor parado. Não invocar PHPUnit directamente na mesma base durante a avaliação da equipa. Separar a base de regressão da demonstração persistente é uma melhoria técnica pendente; este primeiro pacote mantém a limitação explícita.
## Funções de teste
| Função | Conta sintética | Trabalho |
| --- | --- | --- |
| Preparador | gestor@example.test | Preparar propostas, rascunhos, custos, orçamento e operações simuladas |
| Validador | consulta@example.test | Rever versões comerciais, validar custos/orçamento e confirmar recebimentos |
| Técnico | tecnico-oficina@example.test | Registar tarefas, tempos e materiais autorizados |
| Verificador | verificador-oficina@example.test | Verificar apontamentos por identidade distinta |
Estas contas têm direitos explícitos por contexto; não constituem perfis universais nem modelos obrigatórios para clientes. Credenciais aleatórias existentes só no manifesto local protegido `C:\laragon\data\sgf-tempo\secrets\browser-fixture.json`, campo `password`; não copiar para chat, relatórios, capturas ou repositório. Os ficheiros de entrada anteriores também podem ser usados enquanto correspondam ao manifesto. Não há credenciais reais necessárias para estes percursos.
## Guião da equipa
1. Entrar como preparador; seleccionar Empresa Horizonte / Unidade Central. Consultar clientes, serviços, prestação e rascunho. Conferir documentação, versões e pendências; criar outro caso para praticar B1 sem alterar o exemplo de correcção.
2. Na oficina existente, consultar autorização e apontamentos; entrar como técnico/verificador para experimentar uma nova ordem. O verificador não pode verificar apontamento próprio; a revisão comercial não concede verificação técnica.
3. Consultar a conversão original de 500 MZN. Em correcção/cancelamento, preparar preços 200/H e 80/UN, fonte sintética v2 e motivo. Validador revê a versão exacta; preparador aplica. Conferir 510, original conservado e origens únicas. Experimentar cancelamento e conferir histórico, sem reabrir consumo.
4. Preparar custo directo a partir dos apontamentos existentes, validar distintamente. Conferir 275 conhecidos e experimentar falta de taxa: pendência, nunca zero ou custo completo presumido.
5. Consultar lote industrial/resultado validado de 1 800 MZN. Preparar orçamento 900/600/300, validar, comparar com 1 000/500/300: desvios +100/−100/0 e total zero; residual 300 separado.
6. Consultar factura de teste de 1 000 MZN e respectivo PDF. Em recebimentos simulados, preparar obrigação e dois pagamentos parciais; validador confirma versões, preparador afecta e validador confere. Conferir saldo/histórico e praticar correcção. Não é banco, recibo fiscal ou lançamento.
7. Tentar consultar outra unidade sem direito e executar acção da outra função: a aplicação deve recusar. Não conceder novos direitos reais para resolver dificuldades da demonstração.
Registar feedback por percurso, função, passos, resultado esperado/observado e referência sintética; agrupar correcções numa entrega funcional. Não solicitar aprovação de cada passo técnico. A equipa ainda não validou este pacote: preparação técnica não equivale a experiência visual ou aceitação.
## Implantação
Só na implantação entram pessoas e dados reais, concessões efectivas e enquadramentos fiscais/contabilísticos do cliente. Guardas de emissão e operações reais mantidas. As propostas anteriores para `130000`/cinco concessões na Tempo não são pré-requisitos desta demonstração nem foram aplicadas. Não há publicação ou activação operacional decorrente desta entrega.
## Verificação desta consolidação — 29/10/2026
Preparação transaccional com rollback aprovada antes de guardar; preparação efectiva só na base sintética; repetição/retoma reconheceu o manifesto sem recriar exemplos. Retoma em `http://127.0.0.1:8002`. Verificações HTTP em `scripts/product-demo-check.mjs`: entrada/contexto das quatro funções, consulta dos oito módulos de preparação/validação, oficina pelo técnico/verificador, recusa de módulo sem direito e de unidade reservada. Resultado em [prova HTTP](proofs/product-demo-http.json). Apenas sessão/contexto alterados pela prova HTTP, sem decisões ou operações de negócio. Não se executou suite destrutiva, não se declarou prova visual e não se atribuiu aceitação à equipa.
+6
View File
@@ -1,5 +1,11 @@
# Compras e núcleo financeiro comum — desenho e transição
**T2 sintético implementado, 29/10/2026:** [obrigação de fornecedor sobre factura conferida](SUPPLIER_PAYABLE_DELIVERY.md), fonte única, duas composições, sem pagamentos. Mantém orçamento entregue, demos e pendências da equipa; não equivale a migração ou implantação.
**Orientação comum posterior:** as [oito regras de produto SGF/Compras](SUPPLIER_PAYABLE_SCOPE.md#14-orientação-comum-de-produto-da-tempo--29102026) distinguem documento, dívida, liquidação e contabilidade e orientam os contratos futuros. T2 continua limitado à obrigação a fornecedor; nenhuma ampliação automática a pagamentos/cobranças. Vencimento desconhecido mantém pendência explícita, sem apagar principal nem inventar data.
**Delimitação T2, 29/10/2026:** [factura de fornecedor conferida → obrigação única](SUPPLIER_PAYABLE_SCOPE.md). Identificados saldo implícito existente em Compras, lacunas de vencimento/revisão e compatibilidade de leitura necessária. Ainda sem implementação; o orçamento entregue e a avaliação da equipa são preservados.
**Actualização de execução, 29/10/2026:** primeiro incremento implementado e tecnicamente demonstrado em hosts sintéticos, conforme [entrega e limites](COMMON_BUDGET_DELIVERY.md). A marcação de desenho/não executado abaixo conserva o estado da proposta original. Não há aceitação, implantação ou extracção integral de Compras presumidas; T2 e seguintes não foram iniciados.
29/10/2026. Estado: desenho técnico revisto e primeiro incremento delimitado; reestruturação e provas ainda não executadas. Referência principal para os dois projectos. Não substitui as matrizes de requisitos ou as aceitações existentes.
+107
View File
@@ -210,3 +210,110 @@ Provas a exigir futuramente no recorte aprovado: casos exactos reutilizados IM-F
**Incluído e consumidores futuros:** verificar a adequação do art. 34 à factura pretendida e, se aplicável, unidade, sequência e tratamento de empates com fundamento. Só depois definir extracção/reconciliação/apresentação. HALF_UP/HALF_EVEN e formatos M20/M22 não substituem essa validação. Procurar alterações regulamentares apenas para a disposição/consumidor que possa ser afectado, reutilizando o dossier e os exemplares já identificados.
Não voltam a aprovação valores exactos, ausência de truncagem silenciosa, desconhecido diferente de zero ou história preservada. Não é necessário pedir de novo R1–R6. Nenhuma regra é activada nesta revisão. Não se alteraram código, bases, permissões, fotografias ou portal; não se copiaram implementações nem se executaram testes. Numeração, emissão, PDF, recebimentos, contabilização e publicação permanecem fora do âmbito.
## 10. Auditoria de incorporação fiscal InvesteMoz → SGF — 29/10/2026
Pedido expresso da Tempo nesta conversa: auditar os dois sistemas e identificar como incorporar no SGF as funcionalidades fiscais do InvesteMoz. Estado: auditoria de código e documentação concluída no âmbito abaixo; incorporação funcional proposta, não executada. As secções anteriores mantêm valor histórico; decisões posteriores aceites prevalecem no seu âmbito.
### 10.1 Método e versões
Inspecção local, sem consultas às bases, arranque de serviços, alterações de permissões, migrações ou publicação. Não foram lidos ficheiros de ambiente/credenciais. Não foram repetidos testes aceites nem feita nova prova HTTP ou visual. Os resultados de testes citados são evidências históricas, não execução desta auditoria. Não se avaliou vigência jurídica externa: as fontes e limitações são as documentadas no acervo, não um parecer fiscal novo.
SGF: C:/laragon/www/sgf-tempo, HEAD d071bc0, com alterações posteriores de oficina em curso; preservadas. InvesteMoz: C:/laragon/www/portal-investidor, HEAD fde644af, com alterações documentais anteriores preservadas. Esta cópia principal não contém vários módulos IRPC referidos pelos recibos de instalação. Não se conclui perda no servidor. Para leitura de IRPC usaram-se também as cópias irpc-economic-qa-20261028 e fiscal-forms-qa-20261028.
Conferência adicional: os 12 ficheiros de compatibility.runtime_delta do recibo docs/investmoz/simulations/irpc-l8-grouped-delivery-20261028.json coincidem por SHA-256 com a cópia C:/laragon/www/portal-investidor-fiscal-forms-qa-20261028. A versão de irpc-annual-settlement.ts na cópia economic difere; a versão correspondente ao recibo está na cópia fiscal-forms. Este resultado identifica uma origem verificável, sem afirmar igualdade de todo o projecto nem estado actual do servidor. Antes de extrair, fixar manifesto de todas as dependências transitivas e respectivos testes.
### 10.2 Conclusões
1. A reutilização já começou: ServiceCalculation::SOURCES cita a ficha IVA-GERAL-BASE-LIQUIDA do InvesteMoz. O SGF não parte do zero.
2. O SGF tem cálculo condicionado aceite, perfil e decisão temporal versionados, activação local delimitada, precisão decimal, resultado imutável e repetição idempotente. Ver CONDITIONAL_CALCULATION_ACCEPTANCE.md. Não classificar todo o cálculo como apenas sintético. Exigibilidade pendente continua distinta de cálculo condicionado.
3. ServiceInvoicing::guard mantém emissão real bloqueada fora do ambiente isolado. A emissão/PDF de teste não é emissão fiscal operacional. Não retirar esta guarda por copiar regras do portal.
4. O portal tem cobertura maior de IVA, retenções, IRPC, créditos/reportes e ISPC; parte é preparação/projecção ligada ao dossier e parte é cálculo de operação. Não transformar automaticamente projecção em facto contabilístico ou imposto a pagar.
5. Recomenda-se núcleo fiscal versionado reutilizável, com execução autoritativa no servidor do SGF e adaptadores próprios. Não partilhar a base de dados nem chamar o simulador público em cada factura.
### 10.3 Correspondência de funcionalidades e actividades propostas
Todos os IDs FIS-SGF-01 a 12: Planeado — não implementado por esta auditoria. Ordem abaixo é proposta técnica por dependências, não substitui prioridades aprovadas. Responsável nominal e prazo não atribuídos.
| ID / ordem | Capacidade de origem | Estado SGF / adaptação necessária | Critério de aceitação |
|---|---|---|---|
| FIS-SGF-01 / 1 | Fontes, versões, vigência, parâmetros e expressão fiscal; SimulationTaxRuleExpression, SimulationTaxParameterResolver, SimulationFiscalSnapshot | Reutilizar fontes e casos aceites; adaptar resolução ao tenant/empresa/unidade e perfis existentes, sem importar permissões/publicação do portal. | Regra fixada por versão/hash; fronteiras temporais e conflito/ausência de parâmetros explícitos; histórico reproduzível. |
| FIS-SGF-02 / 2 | IVA de operações, base líquida e enquadramentos; iva-operations, iva-service-location, iva-service-normal, iva-exclusion-base | Expandir gradualmente o recorte actual de uma linha, MZN e preço acrescido. Acrescentar múltiplas linhas/tratamentos e preço incluído somente com regra específica do consumidor. | Casos exactos comparados aos aceites; isenção, não sujeição e zero distintos; soma por linha/documento e diferenças de representação reconciliadas. |
| FIS-SGF-03 / 2 | Facto gerador, exigibilidade e acontecimentos; fiscal-claim-events | Completar ServiceFiscal com acontecimentos efectivos, sem usar data de consulta, próxima facturação ou confirmação B1 como substituto. | Cenários pendente, factura, atraso e pagamento preservam datas e versão; período fiscal só determinado quando sustentado. |
| FIS-SGF-04 / 3 | Retenções IRPC e respectivos fundamentos; irpc-withholdings, irpc-fx, irpc-relief-preparation | Não localizado motor equivalente no SGF. Integrar pagamentos/rendimentos e distinguir retenção efectuada de sofrida, por conta de definitiva. | Pagamento/comprovativo rastreável, contexto do beneficiário, natureza e vigência; sem duplicação nem abatimento automático de toda retenção à colecta. |
| FIS-SGF-05 / 4 | Aquisições, dedução, afectação, regularizações e Modelo A; iva-acquisitions, iva-acquisition-allocation, iva-regularizations, iva-modelo-a | Criar livros fiscais a partir de documentos efectivos, despesas e correcções do SGF; módulos de compras/documentos necessários não presumidos existentes. | Documento→parcela→dedução→período→campo reconciliável; desconhecido não zero; bloqueio de dupla dedução, revisões e créditos conservados. |
| FIS-SGF-06 / 5 | IRPC: resultado, custos, depreciações, base/colecta; irpc-normal e dependências | Substituir entradas projectadas por balancete e movimentos efectivos versionados. Custeio industrial e classificação de catálogo existentes não equivalem a razão contabilístico. | Ponte resultado contabilístico→ajustamentos→lucro tributável→base/colecta explicável; inventário de activos e encargos com origem; sem automatizar ramos não cobertos. |
| FIS-SGF-07 / 6 | Retenções sofridas, PPC, PEC, créditos internacionais/económicos, reportes; irpc-annual-settlement e módulos relacionados | Adaptar ao mesmo contribuinte, exercício e comprovativos efectivos; usar fiscal-proof-parcel para não reutilizar a mesma parcela indevidamente. | Consumo agregado não excede comprovativo; deduções/reportes por origem/período; saldo aritmético separado de declaração, pagamento e reembolso. L8 permanece parcial. |
| FIS-SGF-08 / 5, condicionado à aplicabilidade | Elegibilidade, trimestre e retenções ISPC; ispc-eligibility, ispc-quarter, ispc-withholding | Incorporar apenas para empresas/regimes aplicáveis, sem assumir enquadramento da Tempo nem activar IVA/IRPC/ISPC simultaneamente por omissão. | Elegibilidade explícita e histórico de enquadramento; volume do contribuinte completo; trimestre/ano e projecção/efectivo separados. |
| FIS-SGF-09 / 4–6 | Preparação Modelo A e M20/M22, relatórios e recuperação | Reutilizar correspondências de campos, não tratar formulários manuais como motor integral. No portal, a entrega de formulários de 28/10 tem 27 campos IVA e 24 M22, sem persistência no servidor ou submissão AT. | Campos automáticos reconciliados com livros; campos manuais identificados; gravação/versionamento no servidor SGF; validação distinta de impressão/submissão. |
| FIS-SGF-10 / transversal | Snapshots, históricos, não duplicação e diagnósticos | Manter contexto SGF, revisão distinta, auditoria transaccional, concessões explícitas e imutabilidade; adaptar snapshots por oportunidade para empresa/operação/período. | Tentativas entre empresas/unidades recusadas; replay, concorrência, alteração de versão e revogação testados; originais B1 preservados. |
| FIS-SGF-11 / depois dos livros | Obrigações, calendário e integração operacional | Há pendências no próprio portal para calendário PPC/PEC, juros/guias e fecho integral. Desenvolver especificamente no SGF quando houver fontes e consumidores; não marcar como simples cópia. | Obrigação e prazo com fundamento/período; reconhecimento, apuramento, pagamento e submissão separados e reconciliados. |
| FIS-SGF-12 / primeiro e transversal | Conjunto de casos e provas existentes | Fixar origens por commit/hash, extrair funções puras e comparar resultados com contratos SGF; portar os casos aceites, acrescentando apenas os testes da integração. | Paridade exacta para casos cobertos; diferenças justificadas por contrato; política monetária preservada; sem testes destrutivos fora de sgf_tempo_test. |
### 10.4 Arquitectura recomendada
Proposta, não estrutura já criada: módulo fiscal do SGF que usa CompanyContext, Permissions, Audit e a persistência existente. Separar (a) acervo/fontes e parâmetros por versão, (b) funções puras de classificação e cálculo, (c) adaptadores de operações efectivas, (d) livros/apuramentos, (e) representação documental. O InvesteMoz conserva o adaptador de projecções. Começar pela extracção controlada das funções PHP e portabilidade testada das funções TypeScript; só criar pacote partilhado após fixar contrato e manutenção de versões. Evitar dois motores divergentes mantidos por cópias manuais.
Entrada mínima proposta: versão do contrato, tenant/empresa/unidade, contribuinte, operação/documento/linha e versões, natureza efectiva/projectada, regime/tratamento, moeda, montantes em strings decimais, datas do facto/exigibilidade, período, fonte/regra/versão e comprovativos. A identidade/autorização é resolvida pelo servidor, nunca confiada ao cliente. Resultado: determinado/indeterminado/não aplicável/condicionado, montante exacto, representação documental e diferença, pressupostos/pendências, passos, fontes e hash da entrada. Taxa ausente não é zero.
Os módulos TS do portal dependem de acquisitionScope, modelo_a_snapshot, closureFingerprint e plano empresarial. São dependências do dossier, não a identidade fiscal do SGF. Transferir a lógica não autoriza copiar os snapshots de utilizadores. Resultado enviado pelo navegador deve ser recalculado/conferido no servidor antes de integrar documentos ou livros.
### 10.5 Incompatibilidades e limites concretos
- Precisão: SGF aceita quantidade/preço com até 16 inteiros e 8 decimais, usa BigDecimal e política documental TRUNCATE2, conservando a diferença. fiscal-rational aceita montantes até 18 inteiros/6 decimais e usa BigInt; apresentação aproxima a seis casas. Não reutilizar a apresentação como montante fiscal nem reduzir silenciosamente a precisão do SGF. Exemplo já aceite: base 1000,03, IVA exacto 160,0048, documental 160,00 e diferença 0,0048.
- Consumidor publicado PHP do portal: SimulationPublishedFiscal está ligado a IVA-GERAL-BASE-LIQUIDA/MZN e cobertura técnica 2026. Essa cobertura não é prova de caducidade legal nem motor geral para todos os impostos/anos.
- Fiscalidade versus contabilidade: classificação PGC-NIRF do catálogo e custeio/oficina não substituem reconhecimento contabilístico, activo fiscal, livro de compras, retenções e fecho. Esses adaptadores são trabalho SGF.
- Formulários e L8 não são integrais. O recibo L8 mantém tributação autónoma, certos benefícios, calendários/guias, períodos especiais, outras moedas e transporte/submissão entre as pendências. Não prometer incorporação integral de IRPC.
- IRPS/salários, direitos aduaneiros/ICE e submissão electrónica AT não foram demonstrados nesta amostra como módulos prontos a portar. Não incluir como entrega disponível só por aparecerem no selector fiscal.
- Compatibilidade técnica: ambos usam Laravel 13 e React 19, mas SGF fixa PHP ~8.4 e brick/math 0.14.8; o portal usa Inertia/TypeScript e outras dependências. Reutilização de lógica é viável; transplante de páginas/controladores/esquema não é automático. Não actualizar dependências por arrasto.
- Separação operacional: não partilhar credenciais, base, sessão ou perfis; eventual distribuição de regras deve usar pacote/contrato versionado com origem e compatibilidade, sem enviar clientes/movimentos do SGF ao portal.
### 10.6 Sequência e evidências
Primeiro FIS-SGF-12/01: fixar manifestos, fontes e casos; extrair núcleo e adaptador de cálculo sem emissão. Depois 02/03: consolidar IVA operacional, preservando o cálculo condicionado aceite. Seguir 04/05: retenções e livros de IVA quando as operações/documentos estiverem disponíveis. Depois 06/07/09: IRPC anual, créditos e declarações com integração contabilística. 08 apenas conforme o regime; 11 após cobertura dos apuramentos.
Para cada lote: critérios da tabela, equivalência exacta, ausência de parâmetros, datas limítrofes, alteração de versão, dupla utilização de comprovativos, concorrência/idempotência, segregação tenant/empresa/unidade e preservação de fotografias. Ensaios novos apenas em sgf_tempo_test pelo wrapper scripts/sgf.ps1. Activação operacional é separada da incorporação técnica conforme as decisões já registadas; esta auditoria não executa nem solicita activação.
Evidência SGF: app/Modules/Services/ServiceCalculation.php; ServiceFiscal.php; LocalFiscalActivation.php; ServiceInvoicing.php; CatalogueAccounting.php; docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md; SERVICE_CALCULATION_DELIVERY.md; SERVICE_INVOICE_TEST_DELIVERY.md; tests/Feature/ServiceCalculationTest.php e ServiceFiscalTest.php. Evidência InvesteMoz: serviços SimulationTaxRuleExpression/ParameterResolver/FiscalSnapshot/PublishedFiscal; bibliotecas IVA/ISPC; módulos IRPC das cópias identificadas; recibos irpc-l5-delivery-20261025.json, irpc-l6-values-forms-delivery-20261026.json, irpc-l7-server-state-20261026.json, irpc-l8-grouped-delivery-20261028.json e fiscal-form-preparation-git-delivery-20261028.json.
Próximo recorte recomendado: FIS-SGF-12/01, contrato e paridade de cálculo, preservando emissão, contabilização e recebimentos no seu estado actual. Não é necessário refazer o cálculo de IVA já aceite nem voltar a validar genericamente as fontes oficiais aceites.
## 11. Orientação de produto da Tempo — conhecimento fiscal comum e capacidades próprias do SGF — 29/10/2026
**Delta dirigido FIS-SGF-16:** [percurso orçamental sintético SGF](SGF_BUDGET_WORKSPACE_DELIVERY.md) disponibiliza interface, funções e rastreabilidade sobre o núcleo comum, em base QA própria. Não encerra o requisito nem acrescenta orçamento industrial, obrigações, pagamentos, lançamentos ou modelos. Teste pela equipa/aceitação mantêm estado independente da prova HTTP.
**Cobertura dirigida FIS-SGF-16, 29/10/2026:** [controlo orçamental comum sintético](COMMON_BUDGET_DELIVERY.md), reutilizando dotações/reservas/compromissos/consumo de Compras nos dois hosts. Orçamento industrial permanece distinto. Não encerra FIS-SGF-16 nem implementa contas a pagar, caixa, lançamentos ou modelos; demais requisitos mantêm estado/critério.
**Desenho de Compras confirmado posteriormente:** [um módulo, um núcleo financeiro comum e duas composições](PURCHASING_COMMON_CORE_PLAN.md). Para FIS-SGF-13/14/15/16, preservar procurement existente, extrair capacidades financeiras reutilizáveis de Compras/SGF e atribuir um único escritor a cada efeito; completar apenas lacunas. FIS-SGF-18/19 conservam consumidores e conhecimento versionados. Primeiro incremento: controlo orçamental sintético comum; esta referência não altera critérios/estados funcionais nem declara fiscalidade/contabilidade geral concluídas. Facturação de vendas não é dependência comercial obrigatória de Compras autónomo.
**Decisão posterior de execução, na mesma data:** desenvolvimento e testes destes requisitos usam funções e organizações sintéticas, sem depender de pessoas/concessões na Tempo. Validação da equipa em entregas funcionais integradas; implantação real separada. A conformidade para emissão real não bloqueia desenvolver os restantes consumidores. Ver [demonstração do produto e inventário de capacidades/lacunas](PRODUCT_DEMO.md). Os requisitos FIS-SGF existentes mantêm os seus critérios e estados; esta referência não os encerra.
Clarificação expressa do utilizador nesta conversa: o SGF é uma aplicação com forte componente fiscal transversal à classificação das vendas/compras, stocks, contabilidade geral e de custos, análises financeiras e preenchimento automático de modelos IVA, IRPS, IRPC, INSS e outros aplicáveis. A finalidade é enriquecer o SGF com o conhecimento já tratado no InvesteMoz, sem duplicar esse trabalho, e acrescentar as capacidades operacionais próprias do SGF.
Esta orientação amplia o âmbito de produto da auditoria da secção 10; não limita a integração a cálculo de factura ou cópia de simuladores. A ausência de demonstração de IRPS/INSS na amostra anterior é uma tarefa de localização e correspondência do acervo, não uma exclusão do produto nem uma declaração de que o conhecimento não existe. Reutilizar fontes, interpretações, correspondências, regras, decisões e casos aceites; estudar/implementar apenas lacunas concretas ou diferenças do consumidor SGF. Não exigir nova validação genérica das fontes oficiais já aceites.
### 11.1 Distribuição de responsabilidades
- Conhecimento fiscal reutilizável: legislação e alterações por período, enquadramentos, classificações, condições, fórmulas, parâmetros, correspondências dos modelos, fontes e exemplos/testes documentados. Um registo de origem e versões permite actualizações coerentes, sem repetir a investigação nos dois sistemas. A tecnologia de distribuição é escolha de implementação, não dependência de uma chamada ao portal por operação.
- InvesteMoz: mantém os seus percursos de orientação e preparação do investimento, cenários e simulações. Preservar entregas e acervo; não alterar o seu âmbito para acomodar o SGF.
- SGF: aplica o conhecimento às operações efectivas, com contexto da empresa/unidade, documentos, movimentos, stocks, activos, trabalhadores/remunerações, contabilidade e fechos. Produz análises e modelos a partir dos registos, evitando reintrodução de informação já disponível.
- O estado de uma fonte, a cobertura de uma regra, a implementação do consumidor e a disponibilidade operacional são registados separadamente. Conhecimento disponível não significa consumidor implementado; consumidor por completar não justifica refazer conhecimento já tratado.
### 11.2 Actividades adicionais na execução
IDs FIS-SGF-01 a 12 mantidos e ampliados por esta orientação. Novos recortes abaixo: planeados, não executados nesta actualização. Prioridade por dependências a reconciliar com as entregas em curso; sem atribuição fictícia de responsável/prazo.
| ID | Capacidade SGF a completar | Reutilização / trabalho próprio | Critério de aceitação |
|---|---|---|---|
| FIS-SGF-13 | Classificação fiscal e contabilística de vendas/compras | Reutilizar enquadramentos, condições, vigência e fontes do portal; articular com catálogo e classificação SGF, contrapartes e natureza/localização da operação. | Uma operação alimenta documentos, livros e contabilidade com classificação fundamentada; categoria comercial/conta contabilística não determina automaticamente imposto; excepções e versões rastreáveis. |
| FIS-SGF-14 | Stocks e articulação fiscal/contabilística | Acrescentar entradas, saídas, transferências, devoluções, inventários e valorização; reutilizar regras aplicáveis a bens e regularizações já tratadas. | Quantidade, custo, documento, movimento e consequências fiscais conciliáveis; sem dupla movimentação, origens perdidas ou IVA incorporado no custo indevidamente. Método de valorização identificado, não inventado pela integração. |
| FIS-SGF-15 | Contabilidade geral, activos e fechos | Construir integração documental→lançamentos→razão/balancete→fecho; reutilizar conhecimento sobre depreciações, ajustamentos e apuramentos, preservando classificação existente. | Origem até lançamento e demonstração recuperável; débitos/créditos conciliados; períodos e correcções controlados; depreciação contabilística e tratamento fiscal distinguíveis. |
| FIS-SGF-16 | Contabilidade de custos e análises financeiras | Preservar custeio directo/industrial e orçamento existentes; integrar centros de custo, produção/oficina, margens, tesouraria e indicadores com dados efectivos e projecções identificadas. | Custos/margens conciliam com movimentos e contabilidade; critérios de imputação explícitos; previsto/realizado e efeitos fiscais separados, sem duplicar custos ou caixa. |
| FIS-SGF-17 | IRPS, remunerações e INSS | Localizar todo o conhecimento pertinente no acervo InvesteMoz e mapear cobertura; completar lacunas específicas; acrescentar cadastro laboral, processamento e obrigações próprios do SGF. | Por trabalhador e período, bases, exclusões, retenções e contribuições aplicáveis são explicáveis e conciliáveis com remunerações, contabilidade e modelos; parâmetros ausentes não se tornam zero. Dados pessoais limitados aos acessos próprios. |
| FIS-SGF-18 | Preenchimento automático de modelos e obrigações | Ampliar FIS-SGF-09 para IVA, IRPS, IRPC, INSS e outros identificados como aplicáveis; reutilizar correspondências/formulários existentes e ligar campos às operações/livros do SGF. | Cada campo preenchido automaticamente mostra origem, período, versão e regra; totais reconciliam; lacunas ficam visíveis; correcção manual fundamentada/versionada; modelo recuperável e exportável no formato aplicável. Preparar, rever, entregar e guardar comprovativo são estados distintos; não presumir transmissão automática às autoridades. |
| FIS-SGF-19 | Manutenção do conhecimento comum | Inventariar fontes/regras/casos já tratados, mapear aos consumidores SGF e distribuir versões com impacto identificado; reutilizar testes aceites e acrescentar testes de integração. | Alteração de regra identifica operações/modelos afectados, produz nova versão sem reescrever história e evita investigação duplicada. Pendência localiza o trecho/condição exactos em falta. |
### 11.3 Percurso integrado e sequência
Conhecimento fiscal versionado → classificação da operação → documentos e movimentos → stocks/custos/contabilidade/remunerações → apuramentos → preenchimento automático e reconciliação dos modelos → revisão/entrega/comprovativo. As análises financeiras consomem os dados conciliados deste percurso. Capturar a informação uma vez e reutilizar com origem, contexto, autorização e versão.
Primeiro, correspondência do acervo existente aos consumidores (01/12/19), incluindo IRPS/INSS e modelos, sem refazer a investigação. Em paralelo lógico de planeamento, especificar operações e ligações necessárias (13–17), preservando incrementos aceites. Executar por recortes completos de operação até resultado, acrescentando 18 aos respectivos apuramentos; evitar um projecto isolado de formulários manuais ou uma cópia do simulador. Implementação e activação serão registadas por recorte; esta actualização documenta o âmbito e não declara as capacidades entregues.
+78
View File
@@ -0,0 +1,78 @@
# Percurso orçamental sintético no SGF
29/10/2026. `SGF-BUDGET-WORKSPACE-SIM-v1`. Implementação autorizada do passo seguinte à [aceitação técnica do núcleo comum](COMMON_BUDGET_ACCEPTANCE.md). **Disponível para teste da equipa, sem aceitação ou checkpoint de encerramento presumidos.**
## Disponibilidade e preservação
Entrada local: **http://127.0.0.1:8003**. É o SGF existente: mesmo bootstrap, entrada por sessão, selecção de empresa/unidade, navegação e componentes React. Depois de seleccionar o contexto, abrir **Compras → Controlo orçamental sintético** ou o atalho da página inicial.
A instalação de demonstração tem base PostgreSQL própria `budget_core_sgf_<12 hex>`, uma conta técnica exclusiva sem superuser/CREATEDB/CREATEROLE, chave da aplicação, cookie, sessões e cache separados. Não usa `sgf_tempo_dev`, `sgf_tempo_test` ou `compras_dev`. A demonstração anterior em 8002 conserva contas/dados e não foi substituída. Esta composição demonstra o percurso orçamental no SGF; não reúne automaticamente todos os dados/módulos da demonstração anterior.
O estado inicial mantém quatro contas, centros e origens sintéticos, **sem dotações, decisões ou movimentos pré-preenchidos**. A prova HTTP usa outra base/role descartável em 8004; não consome o cenário que a equipa vai experimentar. A [conferência da disponibilidade](proofs/budget-demo-availability.json) registou quatro utilizadores e zero dotações/movimentos/consumos/comandos antes da revisão da equipa.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/budget-demo.ps1 status
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/budget-demo.ps1 start
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/budget-demo.ps1 stop
```
`start` recusa porta ocupada; o provisionamento repetido preserva a base, pessoas sintéticas, passwords e histórico. `stop` só termina o servidor próprio identificado e conserva os dados. Não há reset automático. Não executar testes destrutivos apontados para a base persistente da equipa. O servidor está limitado a loopback; não foi publicado ou disponibilizado remotamente.
## Contas por função
| Função | Conta sintética | Acções |
| --- | --- | --- |
| Preparação | `preparacao-orcamento@example.test` | Consultar, preparar dotação, propor reforço |
| Validação | `validacao-orcamento@example.test` | Consultar, aprovar dotação, aprovar/recusar reforço |
| Operação | `operacao-orcamento@example.test` | Consultar, reserva/adjudicação/compromisso/libertação e consumo simulado |
| Consulta | `consulta-orcamento@example.test` | Ler saldos, referências, movimentos e histórico; sem escrita |
As passwords aleatórias de cada conta estão exclusivamente no ficheiro local protegido `C:\laragon\data\sgf-tempo\secrets\budget-demo-access.json`, campo `accounts`. Consultá-lo localmente; não copiar passwords para chat, relatórios, capturas ou Git. As configurações de ligação/chave estão noutro ficheiro privado, `budget-demo.json`. Os ficheiros têm ACL do utilizador local/SYSTEM. Não foi alterada conta ou concessão real nem atribuída função a pessoas reais.
Empresa: **Empresa Horizonte — Orçamento sintético**. Unidade: **Unidade Central — Orçamento sintético**. Centro do guião: **ORC-DEMO**. A unidade reservada e a empresa sem membership não são autorizadas. Tenant, empresa, unidade e centro continuam identificadores distintos, sem equiparação automática.
## Guião funcional da equipa
Usar entradas decimais com ponto. Para o exemplo sem imposto desta prova, indicar total e subtotal iguais; isto não configura uma taxa fiscal. Os valores e saldos abaixo são MZN.
1. **Preparação:** entrar, seleccionar empresa/unidade e abrir Compras. Criar dotação de **1000.00** em ORC-DEMO, exercício actual, corrente, base com imposto indicado e fundamento sintético. O estado fica pendente e o total aprovado/disponível é zero, sem autorização presumida.
2. **Validação:** sair/entrar com a conta de validação, seleccionar o contexto e aprovar. Conferir dotação aprovada **1000.00**, actor e data. A preparação não pode aprovar a sua proposta.
3. **Operação:** seleccionar a dotação e a origem **SIM-COMPRA-001**. Reservar **300.00** (total e subtotal): reservado 300, disponível 700. Registar ajuste de adjudicação para **350.00**: reservado 350, disponível 650. Registar compromisso **350.00**: reserva zero, compromisso 350 e disponível 650.
4. **Operação:** em consumo simulado, usar a mesma origem, referência **SIM-CONSUMO-001** e total/subtotal **100.00**. Conferir compromisso 250, consumo 100 e disponível 650. Repetir a mesma referência/valores: não surge segundo consumo. Não é criada factura, obrigação, pagamento ou lançamento.
5. **Preparação:** propor reforço **200.00**, referência **SIM-REFORCO-001** e fundamento. Disponível continua 650 enquanto pendente. **Validação:** aprovar distintamente. Conferir total aprovado 1200 e disponível **850.00**, com dotação inicial 1000 preservada.
6. **Operação:** na origem separada **SIM-COMPRA-LIBERTAR**, reservar 200 e libertar antes de compromisso. Disponível volta a 850. Repetição não duplica a libertação; compromisso posterior à libertação é recusado. Uma origem já comprometida não recebe nova libertação por este circuito.
7. **Consulta:** conferir cartões de saldo, tabela dos deltas, origem, dotação, actor, data, referência do consumo e histórico expansível. Esta conta não mostra formulários de escrita; pedidos directos sem direito também são recusados.
A dotação seleccionada determina os centros/origens apresentados nos formulários; campos de operação só aparecem para dotação aprovada do exercício actual e com origem no mesmo centro. Se o cenário já estiver usado, conservar o histórico: não se reabre ou apaga uma origem para repetir a demonstração. A equipa pode rever/repetir os comandos idempotentes; cenários adicionais devem ser acrescentados explicitamente em QA, sem reset do trabalho anterior.
As alterações de contexto limpam os formulários e não aplicam respostas tardias de outro contexto aos dados apresentados. Em erro de rede, repetir o mesmo conteúdo recupera a mesma chave de comando do formulário; o servidor mantém chaves por actor/contexto/acção e recusa conteúdo divergente.
## Implementação e fronteira do núcleo
- `resources/js/PurchasingBudgetWorkspace.jsx`: interface; recebe saldos do servidor, formata centavos com BigInt e não calcula disponibilidade/impostos. Preparação, operação, validação e consulta filtradas por direitos.
- `resources/js/app.jsx`, `WorkspaceNavigation.jsx`, `WorkspaceHome.jsx`: montagem incremental nos componentes existentes e atalhos por função/unidade.
- `app/Modules/Purchasing/BudgetController.php`: GET e POST do percurso, sob sessão, `auth_version`, CSRF e token de contexto existentes.
- `BudgetWorkspace.php`: valida/normaliza entradas monetárias exactas, referências, sequência do cenário e comandos idempotentes; apresenta movimentos e auditoria. Não calcula deltas ou saldos financeiros.
- `BudgetAdapter.php` e `packages/budget-control`: reutilizados **sem alteração**. O hash do núcleo coincide com as provas da extracção aceite em `f77900e`. Reserva/compromisso/consumo/reforço/libertação continuam com um único motor financeiro.
- Novas tabelas exclusivamente QA: `cb_ui_commands` (recuperação/idempotência de pedidos) e `cb_consumption_sources` (referências de consumo simulado). A coluna histórica `invoice_id` do núcleo aponta, neste ensaio, para a referência sintética; não representa factura emitida ou contas a pagar implementadas.
- `scripts/budget-demo-*`: provisionamento, servidor, acesso e estado. O provisionador usa a ferramenta PostgreSQL local já disponível em Compras para criar base/role **novas**, através da base de manutenção `postgres`; a aplicação SGF usa depois apenas a sua role QA restrita. Não existe dependência de um SGF remoto nem comunicação entre aplicações para executar os movimentos.
## Provas técnicas e limites da revisão visual
Prova reproduzível:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/budget-workspace-proof.ps1
```
[Prova HTTP](proofs/budget-workspace-http.json): quatro sessões reais, login/logout, contexto e funções; dotação/aprovação, sequência 300→350→compromisso→consumo100→reforço200, valores exactos 650/850 disponíveis; referência/consumo únicos; insuficiência de saldo; precisão não representável recusada sem arredondar; falha por consumo excessivo com rollback da referência e recuperação; replay igual e conflito de conteúdo; libertação única sem reabertura; proibição de escrita por consulta; origem de outro âmbito, unidade reservada e contexto antigo recusados; CSRF activo. Base/role da prova removidas no final e sem alterações ao cenário da equipa.
`scripts/budget-workspace-render-check.mjs`: formatação exacta dos valores, deltas negativos, maior valor suportado, desconhecido como indisponível e navegação por unidade. O teste de início/menu existente também passou. Build SGF e sintaxe PHP/PowerShell passaram. Não se instalaram nem actualizaram dependências. A regressão destrutiva SGF não foi apontada para `sgf_tempo_test`; preservou-se a demonstração existente. As provas aceites de concorrência do núcleo mantêm-se; não se atribui concorrência de browsers a esta bateria HTTP sequencial.
**A interface existe agora e está disponível para a equipa.** A validação foi técnica por HTTP, verificação de componentes/formatação e compilação; **não foi realizada nova revisão visual no navegador nem se presume aceitação da equipa**. O bloqueio anterior da automação não foi usado como dependência para implementar ou disponibilizar este percurso. A revisão visual de Compras e os dois testes MFA ignorados continuam pendências da entrega anterior; este incremento não os encerra.
## Limites de implantação e continuação
Sem migração em bases existentes, sem concessões reais, sem utilização real habilitada, sem novo motor e sem alterações ao orçamento industrial. O adaptador continua bloqueado fora do ambiente/base QA. O schema é preparado por scripts sintéticos, não por migrações registadas para aplicar em desenvolvimento/produção. Migração operacional, regras fiscais/contabilísticas e instalação completa de Compras permanecem fora desta entrega.
Próximo passo: a equipa percorre o guião e regista função, referência sintética, passos e resultado observado/esperado. Corrigir dificuldades concretas preservando origens e histórico; decidir aceitação separadamente. Não houve novo commit/checkpoint de encerramento, push ou publicação.
+105
View File
@@ -0,0 +1,105 @@
# Obrigação a fornecedor — entrega técnica em verificação
29/10/2026. `COMMON-PAYABLE-SOURCE-SIM-v1`, [especificação e regras aprovadas](SUPPLIER_PAYABLE_SCOPE.md). **Implementado apenas em QA, sem aceitação ou checkpoint de encerramento presumidos.** O exemplo executado é o aprovado mais recentemente: base **10 000 MZN**, imposto documental **1 600 MZN**, principal **11 600 MZN**. Exemplos anteriores de 1 160 MZN na especificação são históricos; não são os valores desta entrega.
## Resultado e responsabilidade financeira
Uma factura criada e conferida pelos serviços existentes de Compras origina uma proposta e, por identidade distinta, **uma obrigação reconhecida** no núcleo comum. A origem, fotografia, captura/revisão, decisão e histórico são recuperáveis. O principal é exactamente `total_minor=1160000`, sem recálculo de imposto ou presunção de aplicabilidade fiscal real.
O vencimento continua nulo. A consulta apresenta **«Principal reconhecido — vencimento pendente»**, **«Atraso indeterminado»** e **«Dias de atraso: sem valor»**. A consulta com relógio em 2030 conserva estes valores: nenhuma classificação automática de vencida/não vencida ou zero dias de atraso.
Compras já tinha dívida implícita em `ExternalPaymentWorkflow::outstanding` (total da factura menos pagamentos confirmados). A leitura de uma origem reconhecida neste ensaio passa a obter o principal canónico de Payables, revalidando versão/eligibilidade; não mantém outro principal editável. A factura permanece original documental. Para origens fora do ensaio, o percurso anterior mantém o cálculo existente. No ambiente normal a nova capacidade fica desactivada e não consulta tabelas Payables.
Pagamentos do circuito antigo são recusados para a origem reconhecida do novo ensaio. Documentos com **qualquer histórico de pagamentos associado**, mesmo pendente, não entram como dívida integral nova; ficam pendentes/conflituosos. Se aparecer história/alteração incompatível após reconhecimento, o principal histórico é preservado e o saldo fica indeterminado/recusado, não recalculado ou liquidado automaticamente. A migração/reconciliação desses históricos e os novos pagamentos continuam fora do âmbito.
## Fonte comum, adaptadores e interface
| Localização | Responsabilidade |
| --- | --- |
| SGF `packages/payables/src/Payables.php` e `bootstrap.php` | Fonte única: origem estável, propostas/capturas, reconhecimento, unicidade, comandos, histórico e outbox transaccionais. Contexto/actor/pessoa e callbacks explícitos, sem sessão no motor |
| SGF `packages/payables/src/Workspace.php`, `ui/page.php` | Interface HTML partilhada, com preparação, validação e consulta. Escapa conteúdo, usa sessão/CSRF/contexto do host e não permite editar fornecedor, montantes, imposto ou vencimento |
| Compras `app/Services/SupplierPayables.php` | Lê factura, linhas, decisão e pagamentos reais **da base QA**; confere tenant/perfil/permissões sintéticas e fornece a fotografia; projecta a leitura financeira canónica |
| Compras `app/Http/Controllers/SupplierPayablesController.php`, rotas e ligação na página de facturas | Interface/JSON no host autónomo, protegidos pela autenticação/empresa existentes e direitos de função explícitos |
| SGF `app/Modules/Purchasing/SupplierPayables.php` e controlador correspondente | Revalida membership, empresa/unidade, direitos e identidade de pessoa; lê uma projecção do documento efectivamente conferido em Compras, mapeada explicitamente para o contexto SGF |
| SGF `routes/web.php`, `resources/js/app.jsx`, `WorkspaceNavigation.jsx` | Entrada no SGF por **Compras → Obrigações a fornecedores — ensaio**, sem habilitar a função a contas reais |
| Compras `config/common-payables.php` e providers dos dois hosts | Carregamento local do mesmo pacote. Compras aceita artefacto instalado em `packages/payables`/`COMMON_PAYABLES_PATH`; fallback para a fonte vizinha só fora de produção. Sem dependências instaladas/actualizadas |
Não existe outro motor Payables em `App/Services`/`App/Modules`: os adaptadores só tratam origem, autorização e apresentação. O núcleo orçamental aceite não foi alterado. Não se reutilizou contas a receber de clientes como contas a pagar nem se exigiu facturação de vendas. As duas demonstrações são instalações isoladas da mesma capacidade, não duas aplicações a comunicar em cada operação.
## Origem exacta, integridade e versões
O cenário de origem usa fixture própria em `packages/payables/tests/ReceiptFixture.php` para os pressupostos fixos 10 000/1 600/11 600; os comportamentos são executados por `ReceiptWorkflow`, `BudgetWorkflow` e **`InvoiceWorkflow::register/validate` existentes**, não por um novo conferidor. A factura fica `validated`, com actor/data e evento `invoice.validated` recuperáveis. A preparação dessa origem cria os efeitos normais de recepção/conferência **antes** do baseline da prova; a ligação Payables não os repete.
A identidade canónica combina instância de origem persistente, empresa de origem e ID estável da factura; o tipo de origem está delimitado pelo adaptador de facturas de fornecedor. A restrição comercial empresa/fornecedor/número normalizado já é assegurada pelo cadastro Compras. Não existe neste recorte outro importador público que aceite livremente esses identificadores ou monte documentos novos no browser. Importações gerais/aliases entre origens distintas exigirão validação de identidade própria antes de implantação.
`source_revision` é hash canónico do documento/linhas/decisão, não uma coluna de versão nativa inventada. `capture_version` identifica propostas/capturas imutáveis; uma versão nova não é chave para criar outra dívida. IDs locais de apresentação e contagem de pagamentos não entram no hash material, mas o histórico de pagamentos é revalidado separadamente em todas as decisões/replays. O original e todas as propostas anteriores permanecem append-only.
No SGF, o provisionamento importa privadamente uma projecção da mesma factura conferida, produzida pelo adaptador Compras e com recibo/hash de integridade. O hash não é assinatura digital nem uma nova validação de autenticidade; a proveniência desta prova é a execução local documentada dos serviços Compras. Não existe redigitação ou upload de factura no SGF. As provas de alterações de origem usam injecção adversarial em bases descartáveis, nunca nos exemplares da equipa ou em dados reais.
## Atomicidade, estados e segurança
- Um preparador propõe; o validador deve ter outro ID **e** outra identidade de pessoa. Direitos/contexto e o mapeamento de pessoa são revalidados dentro do bloqueio da organização, inclusive após espera e em repetição.
- Origem sem fornecedor, moeda MZN, valores/linhas ou decisão sustentados fica pendente/conflituosa, sem principal zero presumido. Falta de vencimento é independente e não impede o reconhecimento aprovado do principal.
- `pa_sources.source_key` é único globalmente no ledger; `pa_obligations.source_id` é único e não inclui revisão ou chave de comando. Pedidos com chaves diferentes não duplicam a obrigação. Mesmo comando/conteúdo recupera o resultado; conteúdo divergente com a mesma chave é conflito.
- Proposta, obrigação, evento, outbox e resultado do comando são atómicos. Eventos são captura durável, sem publicar para contabilidade ou serviços externos. As tabelas de origem/histórico/obrigação/comando/outbox têm triggers append-only em QA.
- Alteração de origem ou revisão desactualizada produz conflito registado com hash observado e preserva principal/decisão originais. Não altera total, reduz dívida ou substitui original automaticamente. Não há circuito de correcção monetária/anulação nesta entrega.
- CSRF, autenticação e, no SGF, token de contexto mantêm-se activos. Os scripts de falhas/concorrência recusam as bases persistentes da equipa e só operam nas bases descartáveis de prova.
## Demonstrações para a equipa
**Compras autónomo:** http://127.0.0.1:8005/obrigacoes-fornecedores. Entrar normalmente em `/login`; a página de facturas também apresenta a ligação no contexto sintético elegível.
**ERP SGF:** http://127.0.0.1:8006. Entrar, seleccionar **Horizonte Sintética — Obrigações / Unidade de obrigações**, abrir **Compras → Obrigações a fornecedores — ensaio** e a ligação de preparação/consulta. A página partilhada conserva autenticação e contexto; não se abre uma sessão remota no outro produto.
| Função | Conta sintética nos dois hosts | Acções |
| --- | --- | --- |
| Preparação | `preparacao-obrigacoes@example.test` | Consultar origem e preparar proposta, sem redigitar montantes |
| Validação | `validacao-obrigacoes@example.test` | Consultar e reconhecer a revisão exacta por identidade distinta |
| Consulta | `consulta-obrigacoes@example.test` | Origem, principal, pendências, decisão e histórico; sem comandos de escrita |
Passwords aleatórias próprias de cada instalação estão apenas nos ficheiros locais protegidos `C:\laragon\data\sgf-tempo\secrets\payables-demo-compras-access.json` e `payables-demo-sgf-access.json`, campo `accounts`. Consultar localmente; não copiar para chat, capturas ou repositório. Nenhuma conta/concessão real foi alterada.
Guião: preparador selecciona **FT-SIM-11600** e fundamenta; validador consulta a fotografia/captura e reconhece; consulta confirma **11 600,00 MZN**, origem/versão e vencimento/dias nulos. Repetir a preparação/validação deve recuperar a mesma identidade. Não há formulário de pagamento, cobrança, data inventada, stock ou lançamento. O cenário foi preparado sem propostas/reconhecimentos pré-preenchidos; a revisão da equipa permanece por realizar.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/payables-demo.ps1 status
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/payables-demo.ps1 start
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/payables-demo.ps1 stop
```
As duas bases/roles são próprias e restritas (sem superuser, CREATEDB ou CREATEROLE), com sessões/caches/cookies/chaves separados. O provisionamento usa a ligação local de manutenção `postgres` apenas para criar essas bases/roles novas; cada host executa com a sua role QA. `start` retém cenários existentes e recusa portas ocupadas; `stop` só termina os servidores próprios e mantém os dados. O orçamento da equipa em **8003** e a demonstração anterior em **8002** não foram parados, resetados ou usados para estas provas. Tudo está limitado a loopback; não houve publicação.
## Provas técnicas
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/payables-demo.ps1 proof
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/payables-demo.ps1 proof -HistoryOnly
```
Evidências: [duas composições](proofs/payables-http.json) e [recusa de históricos liquidados](proofs/payables-history.json). Bases descartáveis e roles removidas no final. Os SHA-256 do motor e da fotografia inicial coincidem entre os hosts. Não foram copiadas contas/sessões/credenciais entre as aplicações.
**Percurso HTTP real nos dois hosts:** login de três funções, origem conferida com base/imposto/total exactos, preparar, validar distintamente, consultar uma obrigação, repetir com a mesma/nova chave e verificar identidade/saldo únicos. HTML dos três perfis conferido por HTTP: preparação/validação onde autorizado, consulta sem formulários de escrita, principal e pendência apresentados. A origem incompleta não é reconhecida; a revisão antiga é recusada. Vencimento/dias continuam nulos com relógio em 2030.
**Seis cenários PostgreSQL concorrentes**, três por host, com processos/ligação independentes e dois waits de bloqueio observados por cenário: preparação simultânea com chaves diferentes, validação com direito revogado durante a espera (duas recusas), reconhecimento simultâneo (uma obrigação/um evento de reconhecimento). Não se afirma teste de carga ou de todas as ordens de escalonamento.
**Falhas e integridade:** falha SQL injectada antes do evento de reconhecimento reverte obrigação/outbox/comando; repetir a mesma chave após recuperação conclui uma vez. Contexto/unidade e actor de outra empresa, chave com outro conteúdo, ausência de CSRF e source hash/valor alterado são recusados ou sinalizados. Checksums das tabelas de factura/linhas/orçamento/stock/pagamentos antes/depois do percurso coincidiram em Compras. O ledger comum tem um evento durável por efeito, com outbox correspondente.
**Históricos associados:** uma fixture de pagamento confirmado anterior de 300 MZN, exclusivamente na base descartável, deixa o saldo legado de 11 300 MZN preservado e impede constituir uma obrigação como se os 11 600 estivessem integralmente por pagar. A representação SGF usa a projecção desse histórico. O comando novo não altera esses registos, não os confirma outra vez nem os liquida. Esta é prova de recusa/preservação, **não** implementação de pagamentos ou migração de históricos. Também se testou a detecção de histórico incompatível após reconhecimento, preservando o principal e deixando o saldo indeterminado.
**Regressões:** Compras **498 aprovados, 2 ignorados, 4 141 asserções**. PHPStan sem erros e Pint dos ficheiros de produção alterados aprovado. Builds Compras e SGF aprovados, sem actualizar manifests/lockfiles. Uma primeira execução concorrente com o build teve falha transitória do ficheiro de fonte no dashboard; depois de estabilizar os assets, a bateria completa voltou a passar. Não se mudou código para ocultar essa falha. A suite destrutiva SGF não foi executada sobre as demonstrações existentes; o delta foi provado pelo host SGF isolado e pelas verificações de sintaxe/compilação.
Os dois ignorados continuam os testes MFA já documentados em `COMMON_BUDGET_ACCEPTANCE.md`: `users with two factor enabled are redirected to two factor challenge` e `security page is displayed`, dependentes da funcionalidade two-factor desactivada nesta configuração. Não cobrem o reconhecimento monetário deste incremento; a cobertura MFA permanece pendente. **Não foi feita revisão visual em navegador nem se atribui aceitação manual à equipa**: as provas de interface são HTTP/HTML e compilação. Não se contornou o bloqueio anterior de Computer Use. As pendências visuais de Compras/orçamento e MFA são preservadas.
## Ficheiros e migrações
Fonte comum: `packages/payables/{bootstrap.php,src/Payables.php,src/Workspace.php,ui/page.php,qa-schema.php,tests/ReceiptFixture.php,tests/seed.php}`. Adaptadores/controladores e providers acima. Compras: alterações delimitadas em `ExternalPaymentWorkflow.php`, `InvoiceController.php`, `resources/js/pages/invoices/index.tsx`, `routes/web.php`, `config/common-payables.php` e `.gitignore`. SGF: montagem de menu/rotas, `.gitignore` e scripts `payables-provision.php`, `payables-router.php`, `payables-demo.ps1`, `payables-http-proof.mjs`, `payables-probe.php`, `payables-status.php`. Relatórios/provas/instruções documentais actualizados sem encerrar requisitos globais.
`qa-schema.php` não está registado nas migrações operacionais. Cria apenas em QA `pa_access`, `pa_sources`, `pa_proposals`, `pa_obligations`, `pa_commands`, `pa_events`, `pa_outbox`; no SGF acrescenta-se a projecção local `pa_imported_sources` e o direito de navegação QA. Não foram aplicadas migrações em `compras_dev`, `sgf_tempo_dev`, `sgf_tempo_test` ou produção; nem concedidos direitos reais.
Uma futura migração operacional terá de resolver cadastros/IDs definitivos, constraints compostas de pertença e modelo de acessos, preservar origens/comercial identity/versões, ensaiar backup/restauro e reconciliar pagamentos históricos antes da passagem de autoridade. Não se apresenta este schema de fixture como migração pronta para clientes. A distribuição autónoma terá de incluir o artefacto local versionado do pacote; não se criou instalador comercial nesta entrega.
## Limites e continuação
Sem pagamentos, cobranças, afectações, devoluções, notas de crédito, redução/anulação financeira, lançamentos, efeitos adicionais de stock/orçamento ou activação real. Vencimento desconhecido é suportado; a revisão de uma data sustentada e planos de prestações ficam para recorte próprio. O importador SGF é um recibo de QA preparado pelos serviços, não um circuito geral de documentos em produção ou integração remota.
Próximo passo: a equipa testa as duas interfaces e relata função, origem/captura, passos e resultado observado; corrigir deltas sem apagar originais. Aceitação e checkpoint exclusivamente local só depois da decisão do responsável. Sem commit de encerramento, push ou publicação nesta entrega.
+227
View File
@@ -0,0 +1,227 @@
# Factura conferida → obrigação a pagar — primeiro recorte sintético
**Execução posterior, 29/10/2026:** [primeiro percurso implementado e provas](SUPPLIER_PAYABLE_DELIVERY.md). Cenário aprovado de execução: **10 000 + 1 600 = 11 600 MZN**, vencimento desconhecido. A marcação de «não implementado» e exemplos 1 160 MZN abaixo conservam o estado da especificação original; não substituem o relatório de execução. Sem aceitação operacional ou migração real.
29/10/2026. `COMMON-PAYABLE-SOURCE-SIM-v1`. **Conferência do existente e delimitação; não implementado nesta passagem.** Orientação expressa da Tempo: uma factura de fornecedor conferida origina uma obrigação, reutilizando os dados; sem pagamento, stock ou lançamento automático. O orçamento entregue e a avaliação da equipa continuam independentes.
**Especificação completada na mesma data:** secções 9–13 detalham a matriz de reaproveitamento, transição com pagamentos históricos, funções, estados e exemplo para confirmação. A orientação comum posterior da Tempo consta da secção 14 e prevalece sobre escolhas técnicas anteriores: vencimento desconhecido gera pendência documental, sem inventar data, apagar dívida ou se tornar, por inferência, uma regra universal que impeça reconhecer o principal. A passagem de responsabilidade é atómica e sem dois saldos activos. Mantém-se exclusivamente documentação nesta actualização.
## 1. O que já existe
Referências Compras relativas a `C:/laragon/www/compras`:
| Capacidade observada | Evidência | Consequência para o recorte |
| --- | --- | --- |
| Factura recebida por fornecedor/ordem, número normalizado e unicidade | `app/Services/InvoiceWorkflow.php::register`; `database/migrations/2026_10_28_000010_create_invoices.php`, unique tenant/fornecedor/número | Reutilizar o registo existente; não criar outro cadastro de factura ou redigitar fornecedor/montantes |
| Conferência exacta de linhas, preços, unidade, imposto e recepções aceites; parcial cumulativa | `InvoiceWorkflow::lines` e `validate` | Reutilizar a conferência; não recalcular documento com a política de IVA de vendas do SGF |
| Actor de validação distinto, revalidação transaccional e auditoria | `InvoiceWorkflow::validate`, `CurrentActor`, `ConflictGuard` | A origem deve identificar a decisão e a fotografia efectivamente conferidas |
| Consumo do compromisso na validação | `InvoiceWorkflow::validate` → `CommonBudget` → núcleo comum | A criação da obrigação não volta a chamar `consume`, reservar orçamento ou gerar stock |
| Factura/linhas preservadas | `app/Models/Invoice.php`, `InvoiceItem.php` | Não editar montantes/data/número da factura através da obrigação |
| Saldo a pagar implícito já existente | `ExternalPaymentWorkflow::outstanding`: total da factura menos pagamentos externos confirmados | É uma dívida já representada implicitamente. Criar uma segunda base independente de saldo produziria concorrência contabilística/funcional |
| Pagamentos externos, conferência, protecção por referência e validação bancária | `ExternalPaymentWorkflow.php` | Preservar; não refazer, executar ou ampliar pagamentos neste recorte |
| Condições de pagamento congeladas como texto na ordem | `PurchaseWorkflow.php`, `snapshot.quotation.payment_terms` | Texto reutilizável como evidência; não é uma data de vencimento estruturada nem autoriza interpretar automaticamente «30 dias» |
| Sem vencimento estruturado ou coluna de versão na factura inspeccionada | Migração 000010, `InvoiceController.php`, `Invoice.php` | Faltam complemento documental de vencimento e identificação explícita da revisão/fotografia da origem |
| Correcções de facturas/notas de crédito ainda fora do circuito entregue | Imutabilidade de `Invoice.php`; limites de `docs/validacao-tecnica.md` | Não inventar um circuito de correcção fiscal/monetária através de uma edição de obrigação |
No SGF, `ServiceReceivables` trata obrigações **a receber de clientes**, exclusivamente no âmbito demonstrado. Não é contas a pagar a fornecedores. `packages/budget-control` trata efeitos orçamentais e não cria dívidas. O novo serviço comum terá responsabilidade distinta, sem transformar estes módulos em outro motor de contas a pagar.
Esta conclusão resulta de leitura de código/migrações/documentação. Não se consultaram bases, credenciais ou dados reais, nem se executaram testes ou migrações nesta delimitação.
## 2. Capacidade comum e responsabilidade única
Proposta técnica: `Finance/Payables`, fonte única versionada, consumida por adaptadores locais de Compras e SGF. Pode ser um pacote separado do controlo orçamental; não duplicar o seu motor em `App/Services` e `App/Modules`. Mantém o princípio de duas composições comerciais sem obrigar a facturação de vendas SGF ou SGF remoto.
- **Compras:** conserva fornecedor, ordem, factura, linhas e decisão de conferência. O adaptador carrega esses dados da aplicação, revalida actor/organização/estado e prepara a origem normalizada. Não aceitar um total ou fornecedor alternativos enviados pelo browser.
- **Payables comum:** é o único proprietário da obrigação, do principal reconhecido neste recorte, do vencimento associado, das revisões documentais e do histórico. Importar não equivale a efectuar pagamento ou lançamento contabilístico.
- **Leitura de saldo em Compras:** para uma origem abrangida pelo novo núcleo, a base do saldo existente deve ser uma projecção da mesma obrigação. `outstanding()` não pode continuar a manter uma interpretação financeira independente dessa origem. Compatibilidade de leitura é parte da transição, sem desenvolver afectações/pagamentos novos.
- **Origem não abrangida:** o percurso existente mantém o comportamento anterior, sem migração/importação em massa. A existência de histórico não autoriza criar ou liquidar retroactivamente obrigações.
- **SGF:** adapta contexto tenant/empresa/unidade e permissões próprias. IDs de fornecedor/factura de Compras não são IDs de clientes, facturas de venda ou empresas SGF por igualdade numérica.
Antes de disponibilizar a obrigação, provar que as consultas para o documento abrangido apontam à mesma identidade/principal. Se o adaptador de leitura de saldo ainda não existir, a capacidade permanece pendente, não se apresenta uma segunda lista de dívidas como funcionalidade concluída. Nenhum comando de pagamento será disponibilizado no cenário novo deste recorte; os circuitos reais existentes não serão alterados por arrasto.
## 3. Dados reutilizados e dados efectivamente em falta
| Origem reutilizada automaticamente | Complemento necessário |
| --- | --- |
| Empresa/contexto, fornecedor e sua referência documental preservada | Correspondência explícita de contexto no host SGF, sem inventar unidade a partir do departamento |
| Identificador/número da factura, ordem, linhas, moeda, subtotal/imposto/total em centavos | Vencimento estruturado, evidência/base e revisão desse complemento |
| Estado `validated`, actor/data da conferência e histórico | Identidade da fotografia validada, incluindo conteúdo material e decisão |
| Condições textuais da ordem quando disponíveis | Confirmar a data sintética em falta; não deduzir calendário, tolerância ou prazo legal do texto livre |
Para a primeira prova: fornecedor fictício, factura fictícia efectivamente conferida pelo circuito Compras, **MZN**, valor proveniente da factura e vencimento explícito no complemento sintético. Pedir só o vencimento/evidência que não existem; não solicitar nova introdução do valor ou do fornecedor.
Não há coluna `invoice.version` entregue. Não atribuir silenciosamente «versão 1 validada» a um campo inexistente. Proposta: revisão de captura imutável no contrato de origem, com hash canónico do documento/linhas e referência da decisão de conferência. A revisão é do registo de origem do núcleo, com proveniência explícita; não simula versionamento nativo da factura. O adaptador deve detectar qualquer mudança material antes de reutilizar essa referência.
## 4. Identidade, repetição e atomicidade
Identidade canónica: organização explícita + namespace/instância da origem + tipo de documento + identificador estável da factura. Revisão/hash/decisão identificam a fotografia; **não** criar uma obrigação nova só porque apareceu outra revisão ou outro hash da mesma factura. Tenant/número da factura isolados não bastam como chave global; preservar também a unicidade comercial já existente em Compras.
Contrato de origem proposto: identificadores de organização/fornecedor/documento/ordem, referência/edição de captura, hash, moeda, total inteiro, decisão/actor/data da conferência, vencimento/complemento/evidência e indicação de ambiente sintético. Chaves técnicas de comando e identidade de negócio são distintas: comandos novos sobre a mesma origem continuam a resolver a mesma obrigação.
Uma transacção local deve revalidar permissões e a origem sob bloqueio, verificar estado/revisão, resolver unicidade e gravar obrigação/referências/histórico/resultado do comando atomicamente. A origem e a obrigação não dependem de publicação de eventos ou conector remoto para este recorte. Repetição idêntica devolve a mesma identidade; mesma chave com conteúdo diferente é conflito; resposta perdida é recuperável. Índices únicos e bloqueios devem impedir duas dívidas em pedidos concorrentes com chaves diferentes.
O total permanece em centavos exactos e MZN. Não alterar precisão, arredondamento cumulativo da factura ou IVA; não usar câmbio, valor líquido em vez do total devido ou zero como substituto de dados ausentes. A regra de emissão de venda SGF não intervém.
## 5. Pendências e correcções preservando o original
- Factura não conferida, identidade/organização incompatível, moeda não suportada, montante indeterminado, decisão não recuperável ou vencimento em falta: registar motivo e origem da tentativa como **pendente**, sem abrir outra dívida nem reconhecer valor zero.
- Repetir a mesma pendência não acumula dívidas. Completar o vencimento gera revisão do complemento e permite retomar a mesma origem; não revalidar genericamente a factura nem pedir os dados já válidos.
- Corrigir vencimento/evidência: revisão documental sucessora, com motivo, autor, referência à anterior e histórico; mesma identidade de obrigação, sem reescrever a factura ou aumentar o principal.
- Documento/hash materialmente diferente para a mesma identidade: conflito/pendência explícita. Não somar os dois totais nem substituir o principal por «última versão» automaticamente.
- Correcção de valor, fornecedor ou moeda após constituição: fora da execução inicial. Preservar original e sinalizar necessidade de circuito próprio de documento correctivo/revisão. Um novo exemplar sintético não fica automaticamente conferido. Não implementar estorno, nota de crédito, anulação ou baixa financeira por interpretação desta orientação.
As regras de autorização exactas para revisão documental serão explicitadas no contrato de implementação: reutilizar a elegibilidade de Finanças/consulta e os mecanismos de separação existentes; não acrescentar aprovadores empresariais universais. Para as provas, contas distintas de preparação/conferência/consulta, sem pessoas reais ou concessões reais.
## 6. Primeiro recorte e pontos de integração
1. Um fornecedor e uma factura MZN criados/conferidos pelas fixtures e serviços existentes de Compras, com histórico/orçamento necessários preparados em base QA isolada. Manter uma prova de que a nova ligação, depois da conferência, não acrescenta consumo orçamental ou stock.
2. Adaptador de origem Compras carrega a fotografia; núcleo comum resolve pendências e cria **uma** obrigação com valor/vencimento/origem/histórico. Consulta mínima permite recuperar a obrigação e explicar a sua proveniência.
3. Adaptador SGF demonstra a mesma capacidade comum em contexto sintético explicitamente mapeado. Não apresentar uma factura preenchida manualmente no SGF como demonstração da ligação à factura conferida de Compras.
4. Compatibilidade da leitura do principal/saldo do documento abrangido, sem implementar pagamento. Não extrair ou activar todo o `ExternalPaymentWorkflow` neste passo.
5. Tudo em bases QA novas/separadas das demonstrações da equipa. Sem alterar o núcleo orçamental aceite, as bases de desenvolvimento/produção, concessões ou dados reais. Migrações de implantação ficam propostas separadamente; sem importação automática de facturas antigas.
O gatilho pretendido é a conferência da factura. O desenho de implementação deve assegurar que uma falha na ligação não é apresentada como obrigação constituída: transacção local ou pendência recuperável e visível, conforme o recorte adoptado. Não marcar «a pagar no núcleo» antes de a gravação estar demonstrada. A falta de vencimento não apaga a conferência anterior nem volta a consumir orçamento quando a pendência é resolvida.
## 7. Provas a executar na implementação
| Cenário sintético | Evidência exigida |
| --- | --- |
| Factura conferida → obrigação | Mesmo fornecedor/total/MZN, vencimento explícito, decisão e revisão/hash recuperáveis; nenhum dado material redigitado |
| Repetição, chave diferente e dois pedidos simultâneos | Uma identidade de obrigação e um principal, com unicidade de origem reforçada na base |
| Resposta perdida/falha de histórico | Recuperação idempotente ou rollback; nenhuma dívida órfã ou confirmação fictícia |
| Factura pendente/incompleta ou vencimento ausente | Pendência explicada; sem dívida adicional/zero presumido; retomada conserva a conferência |
| Vencimento corrigido | Revisão sucessora e original recuperáveis; mesmo principal, sem segunda obrigação |
| Montante/hash/fornecedor/moeda incompatível | Conflito ou pendência, sem substituir/somar principal automaticamente |
| Leitura anterior e nova para a mesma origem | Mesmo principal/saldo derivado; nenhuma autoridade financeira concorrente |
| Outra empresa/unidade ou direito revogado | Recusa no servidor e na transacção, incluindo replay |
| Conservação dos efeitos anteriores | Contagens e valores orçamentais/stock/pagamentos/lançamentos antes/depois da ligação inalterados |
| Duas composições | Mesmo núcleo/contrato/resultados e origem comprovada; nenhum Sales obrigatório, motor duplicado ou SGF remoto |
As provas ainda não foram executadas neste recorte. Não repetir suites aceites por rotina: reutilizar o orçamento entregue e verificar apenas o delta de integração e as regressões afectadas. A avaliação da equipa em 8003 prossegue em paralelo, sem reset, alteração de contas ou pré-preenchimento desta nova capacidade.
## 8. Estado e continuação
Conferência e desenho concluídos documentalmente. `Finance/Payables`, complemento de vencimento, referência de revisão e adaptação da leitura de saldo **ainda por implementar**, sem migração proposta como já aplicada. Nenhum pagamento, movimento de stock ou lançamento foi criado nesta passagem. Não há nova aceitação funcional ou checkpoint.
Este documento delimita T2 no plano existente; não cria outra matriz global de requisitos. FIS-SGF-13/15/16 mantêm critérios/estados anteriores. A fronteira de correcção monetária e a ausência de vencimento/versionamento nativo são lacunas concretas identificadas, não motivo para reabrir o orçamento entregue ou depender de pessoas reais.
## 9. Matriz consolidada: existente, reutilização e lacuna
| Área | Existe hoje / ficheiros de origem | Reutilização no incremento | Falta / responsabilidade proposta |
| --- | --- | --- | --- |
| Documento de fornecedor | `InvoiceWorkflow::register`, `Invoice`, `InvoiceItem`, migração 000010 | Mesma factura, fornecedor, ordem, linhas e totais conferidos; sem novo cadastro | Adaptador de origem que entrega o documento normalizado a Finance/Payables |
| Conferência | `InvoiceWorkflow::validate`, `CurrentActor`, `ConflictGuard` | Decisão efectiva distinta do autor, revalidada sob bloqueio, com auditoria | Referência estável à decisão/fotografia e distinção explícita face ao reconhecimento no núcleo |
| Dívida e saldo | `ExternalPaymentWorkflow::outstanding` | Reaproveitar total da factura e pagamentos confirmados já associados; conservar resultado | Responsável financeiro explícito e leitor de compatibilidade; nenhuma segunda base de saldo |
| Pagamentos associados | `ExternalPayment`, `ExternalPaymentWorkflow::review`, migração 000012 | Preservar IDs, estado, montante, moeda, actor e histórico; apenas confirmados reduzem o saldo actual | Reconciliação/bindings na futura migração, sem executar ou copiar pagamentos como acontecimentos novos |
| Vencimento | Texto `snapshot.quotation.payment_terms` em `PurchaseWorkflow`; sem `due_on` na factura | Apresentar texto congelado como evidência, sem o reinterpretar | Complemento documental versionado com data explícita, origem/evidência e validação sintética |
| Revisão do documento | Factura e linhas imutáveis pelo percurso entregue; sem coluna `version` | Preservar original e hash material da fotografia validada | Referência da captura no contrato comum, sem fingir versão nativa ou incrementar a dívida por revisão |
| Autorizações | Perfil Finanças/empresa em Compras; memberships e acções empresa/unidade no SGF | Cada adaptador revalida as próprias permissões/contexto | Contrato de actor comum; correspondência explícita dos âmbitos, não equivalência automática |
| Idempotência | Unicidade comercial da factura; pagamentos com referência/hash | Manter restrições existentes | Unicidade da obrigação por origem estável e deduplicação também com chaves de pedido diferentes |
| Histórico | Auditoria Compras, imutabilidade dos modelos | Conservar evidência anterior e ligá-la, sem copiar dados bancários privados | Histórico append-only de tentativas, pendências, decisões e revisões do novo núcleo |
| Orçamento | Núcleo comum aceite, consumo na conferência | Reutilizar prova anterior e manter valores/movimentos | Nenhum efeito adicional na ligação; verificar delta zero |
| Stock e contabilidade | Stock já em Compras; lançamentos gerais não fazem parte desta ligação | Apenas verificar ausência de novos efeitos | Fora deste incremento, sem lançamento implícito nem duplicação de recepção |
| Duas composições | Arquitectura comum aprovada; adaptadores de orçamento já demonstrados | Mesmo padrão de fonte única e contratos locais, sem Sales obrigatório | Implementar Payables e adaptadores num recorte posterior, com QA próprio |
## 10. Origem exacta e contrato de reconhecimento
**Origem elegível:** registo `invoices` da mesma empresa, `status=validated`, `validated_by`/`validated_at` recuperáveis e decisão de auditoria correspondente; ordem emitida MZN e fornecedor/linhas/valores pertencentes ao mesmo contexto. Usar o exemplar efectivamente conferido, incluindo snapshot da contraparte na ordem, não a ficha corrente para reescrever a história. O adaptador deve recusar uma linha preenchida manualmente no SGF que apenas afirme ser «factura conferida de Compras».
O principal da obrigação é `invoices.total_minor`, com `subtotal_minor` e `tax_minor` preservados como componentes documentais. Não usar o subtotal de uma dotação sem IVA como valor devido ao fornecedor. Valores são inteiros em centavos MZN e vêm do servidor; não aceitar principal/fornecedor alternativo no comando de reconhecimento. Conservar arredondamento cumulativo da factura e não aplicar a política de IVA de vendas do SGF.
**Referência estável proposta:** `source_namespace` + `source_instance_id` persistente + `source_company_id` + `document_type=supplier_invoice` + `document_id`. A instância conserva identidade através de backup/restauro; clonar uma base para QA exige namespace de teste explícito, não alterar a identidade para contornar unicidade. A revisão não integra a chave que cria outra dívida: é uma fotografia sucessora da mesma origem. Manter ainda a identidade comercial empresa/fornecedor/número normalizado e, futuramente quando aplicável, edição/série documental. Um segundo ID local com a mesma identidade comercial fica em conflito para conferência, não gera outra obrigação nem é fundido automaticamente.
**Fotografia de origem:** número/data/ordem/fornecedor/empresa/moeda, componentes monetários e linhas materiais, referência da validação, actor/data e hash canónico. Ordenar campos/linhas por critérios estáveis; excluir token de sessão, chave de pedido, consulta actual, pagamentos posteriores e timestamps técnicos sem significado material do hash documental. Uma alteração desses campos técnicos não cria revisão material. A revisão de captura pertence ao núcleo; deve estar identificada como tal porque a factura actual não tem versão nativa.
**Vencimento:** data `YYYY-MM-DD` explícita no complemento, associada à fotografia, com evidência/fundamento e revisão. Não derivar da data da factura, da data actual ou do texto livre por uma regra inventada. Na primeira prova, a data é um pressuposto sintético indicado para confirmação. Data desconhecida permanece `null`, nunca hoje/zero/«30 dias» por omissão. Não confundir vencimento com data de emissão, conferência, reconhecimento ou pagamento.
**Quatro factos diferentes:** conferência valida a factura contra as origens; reconhecimento constitui a obrigação no núcleo e liga a origem; pagamento constitui/liquida um acontecimento financeiro distinto; lançamento reconhece um efeito contabilístico segundo regra própria. Este incremento só propõe o segundo a partir do primeiro. A data do reconhecimento é auditada como data da operação no núcleo e não se apresenta como data de competência contabilística/fiscal. Datas e decisões anteriores mantêm-se.
## 11. Estados, funções e correcções
| Estado proposto | Significado | Efeito sobre principal/saldo |
| --- | --- | --- |
| Origem pendente | Factura ainda não conferida, prova insuficiente ou contexto incompatível | Não constituir obrigação nova; conservar origem/motivos, sem valor zero presumido |
| Complemento pendente | Factura conferida e valor conhecido, mas vencimento/evidência em falta | Guardar uma única candidatura de origem; não somar outro principal. O saldo implícito de Compras continua visível e autoritativo até à passagem |
| Pronta para reconhecimento | Origem e complemento completos/compatíveis, preparados e ainda sem decisão no núcleo | Nenhum pagamento, baixa ou novo efeito orçamental |
| Reconhecida | Decisão sobre a revisão exacta, obrigação única e responsabilidade financeira transferida atomicamente | Principal proveniente da factura; saldo lido exclusivamente pelo responsável comum para esta origem |
| Revisão documental pendente | Proposta de correcção de vencimento/evidência | A revisão aprovada anterior continua aplicável; não alterar principal/data efectiva em silêncio |
| Conflito | Mesma identidade com conteúdo material incompatível, pagamentos inconciliáveis ou associação ambígua | Não gerar segunda obrigação nem substituir/somar principal; sinalizar conflito sem apagar dívida anterior |
«Vencida/não vencida» é uma classificação por data de consulta explícita, separada destes estados e não uma liquidação. Com vencimento desconhecido, classificação desconhecida. «Liquidada/anulada/reduzida» não são comandos deste recorte. Registos históricos já pagos podem conservar essa situação na futura reconciliação, sem a migração executar pagamento ou produzir novo evento de liquidação.
| Função sintética | Preparação/consulta permitida | Separação exigida na prova |
| --- | --- | --- |
| Preparador | Seleccionar factura existente, preparar complemento de vencimento/evidência e submeter revisão exacta | Não aprova a sua preparação nem altera o documento original |
| Validador | Conferir origem/complemento e reconhecer ou devolver fundamentadamente a revisão preparada | Identidade distinta do preparador; não editar a proposta para a aprovar |
| Consulta | Consultar origem, valor, vencimento/pendência, decisão e histórico | Sem criação, alteração, redução, anulação ou pagamento |
Estas são funções do ensaio, não novos cargos obrigatórios nos clientes. Reutilizar actores elegíveis de Finanças em Compras e direitos próprios no SGF; a autorização de validar uma factura não confere automaticamente poderes de gestão de acessos, pagamento ou lançamento. Revalidar membership, âmbito e direito dentro da transacção, mesmo em replay. Uma mesma pessoa representada nos dois hosts não conta como duas identidades para se auto-validar.
Correcção documental cria sucessor com `previous_revision`, motivo, autor, conteúdo, decisão e data. Sem sobrescrita da factura, fotografia ou decisão anterior. Alterar vencimento exige nova revisão documental exacta; mudar o hash/valor/moeda/fornecedor não é mera correcção de vencimento. O primeiro recorte recusa substituição financeira/redução/anulação/baixa e mantém a obrigação anterior visível até existir circuito próprio aprovado. Um novo exemplar do promotor/utilizador não herda a conferência do anterior.
## 12. Transição que preserva pagamentos associados
O primeiro cenário de construção é novo e sintético, sem pagamento. A futura migração de históricos é especificada para impedir perda de dívida ou dupla dedução, **não será executada neste incremento**:
1. Inventariar por factura a identidade comercial, total conferido, pagamentos associados e estados/revisões. Confirmar que não existe já binding da mesma origem à obrigação comum. Não transportar detalhes bancários cifrados para relatórios ou assumir que hash prova pagamento.
2. Reconciliar o saldo anterior: total da factura menos a soma dos pagamentos `confirmed`, exactamente como `outstanding()` hoje. `pending`/`rejected` não reduzem esse saldo, mas mantêm histórico e ligações. Não marcar pendentes como confirmados por importação.
3. Criar/recuperar a obrigação com o **principal original**, ligar os acontecimentos históricos confirmados uma única vez pela sua identidade estável e conservar os IDs/decisões existentes. Não criar obrigação apenas pelo saldo residual e depois deduzir novamente os mesmos pagamentos.
4. Conferir igualdade exacta do saldo antes/depois, integridade da moeda/organização e ausência de pagamentos sem origem. Excesso de pagamentos, divergência monetária ou correspondência ambígua ficam em conflito; não limitar automaticamente a zero, inventar crédito ou pedir novo pagamento.
5. Na mesma transacção de passagem, activar o binding de responsabilidade e fazer a leitura legada da origem delegar no núcleo. Antes da passagem, só o saldo legado é autoritativo; depois, só a projecção comum. O registo da factura continua como original documental, não como outro ledger concorrente. Não somar as duas representações em relatórios.
6. Serializar passagem e qualquer escritor antigo de pagamento para não perder confirmação concorrente. Esta coordenação e o adaptador de leitura têm de ser demonstrados antes de ligar históricos com pagamentos; se faltarem, essa migração fica explicitamente pendente, sem afectar o âmbito sintético novo.
Unicidade deve ser imposta pela base tanto em `organisation/source/document` como no binding do pagamento histórico. A repetição através de outra chave de comando recupera a mesma obrigação e os mesmos bindings. Conteúdo diferente na mesma identidade gera conflito. Em falha de histórico/resultado de comando, rollback da transacção; após perda de resposta, devolver a identidade já comprometida. Não criar dívida na primeira gravação e «corrigir duplicados depois».
Critérios adicionais de aceitação propostos, a juntar aos da secção 7:
- Vencimento desconhecido: uma pendência com `due_on=null`; não inventar data nem apagar o saldo anterior; completar/revalidar retoma a mesma origem.
- Duas propostas/pedidos com chaves diferentes e a mesma factura: uma obrigação; decisões concorrentes não aprovam duas revisões incompatíveis.
- Alteração de vencimento após preparação: decisão sobre revisão antiga é recusada; original permanece recuperável. Alteração material do documento não herda reconhecimento.
- Mesmo número com espaços/caixa diferentes e mesmo fornecedor: respeitar normalização/unicidade comercial; não criar segunda dívida por outro caminho de importação.
- Proponente/validador ou âmbito revogado durante espera: recusa após bloqueio; nenhum efeito parcial; replay também verifica autorização.
- Falha antes/depois do commit e recuperação: apenas um principal, uma decisão efectiva e um binding por origem.
- Reconstituição histórica proposta: pagamentos confirmados ligados uma vez, pendentes preservados sem dedução; valores antes/depois iguais. Esta prova pertence à preparação de futura migração, não a pagamentos executados no primeiro recorte.
- Nas duas composições: mesma implementação e fotografia de origem verificável, sem sessão no núcleo ou dependência de Sales/SGF remoto; nenhum reset da demonstração orçamental e nenhum efeito adicional nos seus movimentos.
## 13. Exemplo numérico para confirmação — não executado
Empresa **Horizonte Sintética**, fornecedor **Fornecedor Alfa Sintético**, ordem **OC-SIM-001**, factura **FT-SIM-001** emitida em **29/10/2026** e conferida por actor sintético distinto. Base **1 000,00 MZN**, imposto documental **160,00 MZN**, total **1 160,00 MZN**. Os 160,00 são um valor já conferido nesta hipótese de documento, não uma conclusão sobre taxa/dedutibilidade fiscal ou uma operação a recalcular no SGF.
| Passo proposto | Resultado esperado |
| --- | --- |
| Seleccionar a factura conferida | Reutilizar fornecedor, ordem, data, base/imposto/total, linhas e decisão; principal candidato 116000 centavos |
| Vencimento inicialmente desconhecido | Pendência explícita, data nula, sem obrigação adicional/zero; dívida implícita de 116000 continua preservada |
| Preparador indicar **28/11/2026**, evidência «Condição sintética acordada para o ensaio» | Criar revisão do complemento, sem alterar a factura. A data é fornecida explicitamente; não foi calculada por regra automática de 30 dias |
| Validador distinto reconhecer a revisão exacta | Uma obrigação de **1 160,00 MZN**, saldo **1 160,00**, vencimento 28/11/2026; binding de responsabilidade único e histórico |
| Repetir com a mesma ou outra chave | Recuperar a mesma obrigação; dívida continua **1 160,00**, nunca 2 320,00 |
| Tentar outro total para FT-SIM-001 | Conflito; principal/original preservados, sem nova dívida |
| Propor vencimento **30/11/2026** com fundamento e validar sucessor | Nova revisão documental; principal permanece **1 160,00** e a data/revisão anteriores continuam recuperáveis |
**Exemplo apenas de futura reconciliação histórica:** se a mesma factura tiver pagamento associado **confirmado de 300,00 MZN** e outro **pendente de 100,00 MZN**, o saldo anterior e posterior deve ser **860,00 MZN**. O principal comum continua 1 160,00; os 300,00 são ligados uma vez; os 100,00 pendentes não são deduzidos. Não criar principal 860,00 e voltar a deduzir 300,00, nem importar como dívida nova de 1 160,00 ignorando o pagamento. Nenhum desses pagamentos será criado ou executado na primeira prova.
**Para confirmação do recorte:** proposta de reconhecimento por validador sintético distinto da origem/revisão exacta, com vencimento explícito ou desconhecido sinalizado conforme a orientação posterior da secção 14; correcções apenas documentais de vencimento/evidência; alterações monetárias e migração de facturas com pagamentos ficam fora da execução inicial. Não se presume autorização de utilização real. Orçamento e avaliação da equipa mantêm-se inalterados.
## 14. Orientação comum de produto da Tempo — 29/10/2026
**Confirmação expressa posterior da Tempo, na mesma data:** no cenário sintético, principal reconhecido e vencimento pendente podem coexistir. Sem data de vencimento sustentada, `due_on=null`, situação de atraso **indeterminada** e `days_overdue=null`; não usar zero, data da factura, data de reconhecimento ou data actual como substitutos. A interface apresenta «Principal reconhecido — vencimento pendente» e «Atraso indeterminado». Não classificar automaticamente como vencida/não vencida, nem calcular dias de atraso. Este critério não activa pagamentos, cobranças, juros ou lembretes.
**Critério de aceitação adicional:** reconhecer a obrigação sintética de 1 160,00 MZN com data desconhecida, avançar a data de consulta e recuperar o registo; principal e identidade mantêm-se, vencimento e dias de atraso continuam nulos e não surge classificação de vencida. Só após guardar uma revisão com data sustentada é possível classificar por uma data de consulta explícita, preservando a situação documental anterior. Trata-se de prova futura especificada, não executada nesta actualização.
Origem: oito regras de produto propostas expressamente pela Tempo para SGF e Compras. São referência do desenho comum, separadas de obrigações fiscais específicas; não constituem prova de implementação, conformidade fiscal, aceitação funcional ou activação operacional. O incremento em preparação continua limitado à obrigação a fornecedor; pagamentos, recebimentos, cobranças e lançamentos não entram por consequência desta orientação.
| Regra comum | Aplicação ao desenho | Âmbito de implementação |
| --- | --- | --- |
| 1. Documento, dívida e liquidação separados | Factura é origem documental; reconhecimento é facto próprio; comprovativo, encomenda e reserva não provam liquidação nem dívida reconhecida | Neste recorte: factura conferida → obrigação única. Não criar estado «paga» por constituição/importação |
| 2. Condições explícitas | Guardar moeda, vencimento, condições e, quando aplicável, prestações; desconhecido visível e sem datas inventadas. Banco/instrução não é dinheiro | Neste recorte: MZN, complemento de vencimento/condições e pendências. Modelação completa de prestações fica futura; não inventar uma prestação ou plano por omissão |
| 3. Liquidações e afectações parciais | Várias liquidações por documento e uma liquidação para vários documentos compatíveis; saldo deriva de afectações, sem edição directa. Excesso permanece não afectado | Evolução futura do núcleo; não implementar agora pagamentos/recebimentos, afectações, reembolsos ou créditos. Preservar a dívida/pagamentos históricos numa futura transição |
| 4. Confirmação e reconciliação distintas | Registo proposto, confirmação com evidência e reconciliação com caixa/banco são factos diferentes; upload não marca factura paga | Preservar essas distinções no contrato/histórico; não acrescentar reconciliação neste recorte |
| 5. Protecção contra duplicação | Repetição recupera o resultado; origens, referências e afectações têm identidade e unicidade inclusive em concorrência | Neste recorte: unicidade da obrigação por factura, independentemente da chave de pedido; conteúdo incompatível gera conflito |
| 6. Correcções rastreáveis | Confirmados não são apagados; correcção/reversão explícita liga ao original e conserva motivo/autorização/histórico. Mercadoria, factura e dinheiro têm circuitos distintos | Neste recorte: revisões documentais e conflitos sem sobrescrita. Reversões monetárias, devoluções, anulações e baixas não são activadas |
| 7. Cobrança e aprovação | Acompanhar vencidos, prestações e contestação; lembretes, juros, descontos e bloqueios exigem configuração; separar poderes quando o circuito exigir | Neste recorte: consulta de vencimento conhecido/desconhecido e funções sintéticas. Não enviar mensagens, cobrar encargos ou impor bloqueios por omissão. Circuito completo de contestação/cobrança fica futuro |
| 8. Um núcleo e responsabilidades | Compras fornece a factura conferida; Vendas o documento do cliente; Finance gere obrigações/recebíveis/liquidações; contabilidade trata os acontecimentos próprios sem repetir stock/orçamento | Fonte única Payables, adaptadores locais e responsabilidade financeira única. Não copiar o motor nem ligar obrigatoriamente Sales ou SGF remoto |
**Precisão sobre vencimento e estados:** a proposta técnica anterior que exigia completar o vencimento antes de constituir a obrigação não se transforma em regra de produto. Se a origem/valor/decisão forem suficientes para reconhecer o principal, o desenho deve suportar **obrigação reconhecida com vencimento desconhecido**, `due_on=null`, pendência visível e classificação de atraso desconhecida. A pendência não impede recuperar a mesma identidade nem autoriza inventar data, apagar dívida ou duplicar o principal. Incompletude material da origem, montante, moeda ou autorização continua pendência/conflito de reconhecimento, distinta da falta de data. O estado «complemento pendente» da secção 11 passa a ser dimensão documental independente; pode coexistir com reconhecimento, sem um segundo saldo.
O exemplo de 1 160,00 MZN mantém principal e impostos do documento. Se reconhecido antes de se conhecer o vencimento, continua 1 160,00 com data nula/pendência; completar 28/11/2026 gera revisão documental da **mesma obrigação**. Não é uma nova dívida nem pagamento. Uma encomenda/reserva anterior permanece outro facto e não é promovida a obrigação por si só.
Esta orientação não revoga dados, decisões, pagamentos ou provas já preservados. As limitações existentes — incluindo dívida implícita de Compras, falta de versão nativa e funcionalidades de liquidação ainda não comuns — continuam identificadas. As capacidades futuras serão demonstradas por incrementos próprios, sem ampliar já esta entrega.
@@ -0,0 +1,59 @@
# Correcção e cancelamento de rascunhos de oficina — entrega em verificação
**Verificação técnica concluída:** regressão completa **183 testes / 3 209 asserções, zero falhas/erros**, incluindo oito casos novos; seis cenários concorrentes e build aprovados. Ver [resumo de provas](proofs/workshop-corrections-tests.json). Referências abaixo a regressão em execução são históricas. Prova manual/capturas e aceitação permanecem pendentes.
28/10/2026. `WORKSHOP-BILLING-CORRECTION-SIM-v1`, [âmbito e decisões validadas](WORKSHOP_BILLING_CORRECTION_SCOPE.md). Preservados `3932b94`, `30029b9`, `d071bc0` e anteriores. Autorizada só implementação/prova sintética, sem migração/concessões em desenvolvimento ou utilização real. **Sem aceitação presumida ou checkpoint de encerramento.**
## Comportamento
Pedidos imutáveis por geração, com predecessor explícito, versões exactas dos dois destinos, motivo, origens e composição comercial. Tipos: substituição, cancelamento e desistência do pedido anterior não aplicado. Osmane sintético prepara/aplica; Samira sintética aprova/recusa a versão exacta por identidade distinta, sem editar/aplicar ou verificar a oficina por consequência. Direitos próprios e contexto revalidados no servidor e na transacção; nenhum nome/email especial no código funcional.
Substituição cria nova prestação e novo rascunho via `ServiceWorkflow`, com relação append-only ao par anterior, sem editar/apagar originais. Um destino actual por cadeia; a aplicação anterior consome a elegibilidade do destino antigo, não a origem técnica. `wb_claims` permanece com duas utilizações originais, sem novas linhas de consumo/tempo. Mesmo cliente, moeda MZN, origens e quantidades integrais; não se aceitam campos para os mudar. Preços, fonte, descrição e serviço comercial podem mudar com versão exacta e revisão. O catálogo actual não tem unidade comercial tipada: a unidade/quantidade física é conservada da origem e a adequação do serviço é matéria da revisão comercial, sem conversão implícita.
Exemplo: original 1,75 H × 200 + 2 UN × 75 = 500 MZN sem imposto; substituto 1,75 H × 200 + 2 UN × 80 = 510. Fonte sintética sucessora `WORKSHOP-SALES-SYNTHETIC-v2`. Não é custo, margem, total fiscal ou quantia exigível. Zero explícito é distinto de preço ausente; exactidão decimal/racional herdada, sem float ou regra fiscal de arredondamento. Imposto/moeda funcional/classificação mantêm pendências.
Cancelamento acrescenta estado de preparação cancelada e preserva origens, par actual e histórico; não cria novo par, não liberta origens e não admite reabertura neste recorte. Não anula ordem, apontamentos ou documentos fiscais. Desistência cria sucessor de pedido sem aplicar mudança aos destinos. Pedidos materiais novos não herdam decisões; referência antiga, nova dependência, cadastro ou origem alterados exigem nova revisão.
## Elegibilidade e integrações
`WorkshopCommercialState` centraliza ligação e estado para originais/sucessores. Edição directa e nova facturação da mesma prestação bloqueadas em A e no SQL; estados comerciais visíveis no detalhe. B1 recusa confirmar destinos substituídos/cancelados, antes de replay; trigger SQL reforça estado e serializa com o bloqueio da empresa. Se B1 vencer a aplicação da correcção é recusada; se a correcção/cancelamento vencer a confirmação antiga é recusada. Não se altera confirmação ou fotografia existente. Preparação documental não pode editar destinos históricos.
Aplicação exige par ainda draft, sem B1 e sem outros rascunhos derivados; origem/tarefa verificada actual e cliente/moeda compatíveis. Limite conservador: se já existir preparação documental específica no par, a operação é recusada até circuito próprio de herança. Nenhum conteúdo privado de instruções documentais é copiado para pedidos, auditoria ou provas. IDs/versões dos registos documentais da empresa compõem a referência: mudança invalida aprovação mesmo que seja conservadoramente noutro registo. Casos com herança documental, documentos fiscais, repartição parcial e reabertura continuam fora do recorte.
Chave idempotente por geração/actor/acção/conteúdo, replay com permissão revalidada, conflito de conteúdo e recuperação após resposta perdida. Bloqueio de empresa serializa comandos; pedido/decisão/aplicação, prestação/rascunho, auditoria/evento/outbox são atómicos. Falha de outbox desfaz sucessores e estado; SQL reforça ambiente, cadeia, identidade, referência aprovada, destino e imutabilidade. Não é RLS ou segundo motor comercial independente contra administrador de confiança. Não são desligados triggers nem usadas flags de sessão para autorizar correcções.
## Provas e percurso
Primeiros seis testes de correcção aprovados (89 asserções). Acrescentados testes de B1 posterior à revisão, preço zero/ausente e protecção SQL, com regressão completa em execução. Seis casos com processos sobrepostos aprovados: pedido repetido, substituição versus cancelamento concorrentes, decisão repetida, aplicação repetida, decisões concorrentes e cancelamento versus B1. Ver [concorrência](proofs/workshop-corrections-concurrency.json). Nesta execução concorrente resultaram uma substituição, um cancelamento, duas utilizações técnicas originais e nenhuma confirmação B1. Compilação aprovada com Vite 8.3.5, sem mudança de dependências.
Novo destino **Oficinas → Correcção e cancelamento**, com consulta/preparação/revisão/aplicação filtradas por permissões. Origens/quantidades não editáveis; pedido mostra base anterior e nova soma, destinos exactos e histórico. Conversão original também identifica substituição/cancelamento e último sucessor; consulta do rascunho apresenta estado comercial. Não há decisão automática nem confirmação B1 automática.
**Prova visual/manual ainda pendente.** Não repetida a automação bloqueada do Edge nem declaradas capturas. Preparação isolada, só com base de testes vazia e testes parados:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 start -Port 8002
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/workshop-corrections-fixture.php --env=testing
```
Fixture prepara apenas a conversão original de 500 pelos percursos existentes com identidades distintas, sem pedidos ou decisões de correcção predefinidos. Contas `gestor@example.test` e `consulta@example.test`; credenciais aleatórias só no manifesto/ficheiro local protegido `C:\laragon\data\sgf-tempo\secrets\workshop-corrections-manual-entry.txt`, nunca no relatório/capturas. Preparador escolhe geração, mantém trabalho 200 e altera material para 80, indica fonte v2 e motivo, submete; revisor aprova versão exacta; preparador aplica. Conferir original 500 histórico, novo 510 actual, linhas e origens preservadas. Depois preparar cancelamento do destino actual, rever distintamente e aplicar; conferir estado cancelado e ausência de reabertura. Não confirmar B1 na prova manual, nem executar testes destrutivos enquanto activa. Parar apenas a instância SGF com `scripts/browser-proof.ps1 stop`.
Instância efectivamente arrancada em 8002 depois dos testes finais e fixture de suporte criada. Diagnóstico HTTP confirmou ambiente `browser-testing`, base `sgf_tempo_test`, papel `sgf_test_runner`, PostgreSQL 55432, sessão/cache em ficheiros isolados: [prova de ambiente](proofs/workshop-corrections-environment.json). Ficheiro de entrada e manifesto com ACL apenas do utilizador local/SYSTEM, fora do repositório. Credenciais anteriores de outros ensaios não devem ser reutilizadas. Nenhum login/fluxo visual de correcção é declarado executado por esta preparação.
## Migração proposta — NÃO aplicada
`2026_10_28_130000_create_workshop_billing_corrections.php`, SQL `database/sql/workshop-corrections.sql`. Destino futuro proposto `sgf_tempo_dev` / `127.0.0.1:55432`, por caminho exclusivo com `sgf_dev_owner`; aplicação mantém `sgf_dev_app`. Exige autorização específica separada. Cria seis tabelas `wc_requests`, `wc_reviews`, `wc_applications`, `wc_commands`, `wc_events`, `wc_outbox`, constraints/FKs/índices/triggers e funções próprias; amplia CHECK com quatro permissões de unidade, sem concessões automáticas. Reforça funções de protecção dos rascunhos ligados já existentes e acrescenta trigger de elegibilidade em B1; não altera linhas ou fotografias existentes. INSERT nas seis tabelas exige `sgf_tempo_test`/55432, guarda operacional exige ambiente isolado seguro. `down()` recusa destruir história; nenhum reset de desenvolvimento ou instalação de dependências.
## Cinco concessões propostas — NÃO aplicadas
Consulta read-only `scripts/workshop-corrections-permissions-preview.php` confirmou `130000` ausente, cinco concessões ausentes, pré-requisitos existentes e operação real bloqueada. Todas no **tenant #1 / Tempo Lda #1 / SEGI #1**:
| Conta existente | Direitos exactos propostos |
| --- | --- |
| Osmane #1 | `workshop_corrections.view`, `workshop_corrections.request`, `workshop_corrections.apply` |
| Samira #2 | `workshop_corrections.view`, `workshop_corrections.review` |
Não herdar direitos da conversão, custos, contabilidade ou nomes. Não concede supervisão/verificação técnica, gestão de acessos, emissão, stock ou uso real. `110000`/`120000` instaladas anteriormente permanecem preservadas, sem reaplicação ou novas concessões por arrasto.
## Ficheiros e limites
Novos: módulo `WorkshopCorrections.php`, estado comum `WorkshopCommercialState.php`, controlador, migração/SQL, `WorkshopCorrectionsWorkspace.jsx`, testes e fixture, scripts de concorrência/worker/fixture/pré-visualização e relatório/provas. Integrações em `ServiceWorkflow`, `InternalConfirmation`, `DocumentPreparation`, consulta `WorkshopBilling`, router, navegação/início e vistas de rascunho/conversão. Sem nova operação real, confirmação automática, fiscalidade, stock, recebimentos, lançamentos, publicação, aceitação ou checkpoint.
+80
View File
@@ -0,0 +1,80 @@
# Oficina → rascunho: correcção e cancelamento controlados
**Autorização técnica posterior de 28/10/2026:** o utilizador autorizou implementar e demonstrar `WORKSHOP-BILLING-CORRECTION-SIM-v1` exclusivamente em `sgf_tempo_test`, sem aplicar migrações/concessões em desenvolvimento, activar uso real ou publicar. Substitui apenas as restrições históricas de etapa documental, preservando decisões/limites. Ver [entrega e provas](WORKSHOP_BILLING_CORRECTION_DELIVERY.md); sem aceitação antecipada.
28/10/2026. Proposta `WORKSHOP-BILLING-CORRECTION-SIM-v1`. Autorizada nesta etapa apenas a especificação. Sem implementação, migrações, concessões, dados reais ou publicação. Preservar a entrega aceite `3932b94`, instalações técnicas `30029b9`/`d071bc0`, checkpoints anteriores, fotografias e limitações de prova. Uso real continua bloqueado.
## 1. Problema e fronteira
O primeiro recorte gera uma prestação e um rascunho ligados à proposta revista, e bloqueia a sua edição e a reutilização das origens. É necessário corrigir a preparação comercial sem apagar o original nem facturar duas vezes o mesmo recurso. Cancelar esta preparação não cancela a ordem, a execução técnica, um documento fiscal, uma dívida ou um lançamento.
Proposta mínima: mesma ordem/empresa/unidade, cliente e MZN; origens seleccionadas integralmente; apenas destinos ainda em rascunho, sem confirmação B1 e sem documento derivado. Correcção de preço, fonte comercial, descrição ou serviço compatível, mantendo quantidades técnicas, fica no primeiro recorte. Mudança de cliente, moeda, repartição parcial, inclusão/exclusão de origens e origem técnica corrigida ficam bloqueadas para revisão de âmbito própria. Assim não se altera implicitamente a ordem nem o vínculo comercial original.
Uma correcção técnica permanece no circuito de oficina e exige nova verificação técnica; a revisora comercial não edita apontamentos ou verifica execução. Se a origem/tarefa seleccionada ficar obsoleta, assinalar o destino como desactualizado e bloquear a aplicação da correcção comercial deste recorte. Não usar aprovação anterior para legitimar dados novos.
## 2. Modelo proposto: substituição, não edição
Preservar prestação/rascunho, proposta, decisão, geração e utilizações originais. Correcção cria pedido versionado que referencia a geração e versões exactas dos destinos, motivo e composição anterior/proposta. Submissão é imutável; qualquer mudança material cria sucessora explícita e nova decisão distinta.
Após revisão aprovada e nova conferência de elegibilidade, criar **nova prestação e novo rascunho**, com relação explícita «substitui/substituído por». Marcar o par antigo como comercialmente substituído por registo de estado append-only, sem alterar as suas linhas ou fotografias. Nunca apresentá-lo como destino actual ou elegível para confirmação por outro percurso. Manter consulta histórica, inclusive a partir das origens técnicas.
O registo de utilização canónica das origens continua único: não apagar `wb_claims` nem inserir o mesmo consumo como nova ocorrência. Propor cadeia própria de destinos comerciais sobre essa utilização, com no máximo um destino actual. Aplicar a substituição, o novo destino e a mudança de elegibilidade atomicamente; não pode existir janela com ambos activos ou com a origem livre.
Os triggers actuais continuam a proibir edição directa. Uma implementação futura precisará de um caminho de sucessão próprio, limitado e revisto, sem desligar triggers, usar flags de sessão como autorização ou permitir gerar novo rascunho pela edição normal de A. Não basta acrescentar um botão: a base e todos os percursos de confirmação devem reconhecer o destino substituído/cancelado.
## 3. Cancelamento proposto
Pedido de cancelamento identifica geração/destinos e versões, motivo, autor e revisão distinta. Aplicação aprovada acrescenta estado «Preparação comercial cancelada», preservando os dois rascunhos e as origens como histórico. Não apagar ou anular acontecimentos técnicos; sem nota de crédito, devolução de stock, reembolso ou comunicação externa.
**Recomendação para o primeiro recorte:** cancelar não liberta automaticamente origens para nova cobrança. Mantém-se a utilização canónica e bloqueiam-se novos destinos; reabertura exige novo pedido versionado com revisão distinta e âmbito próprio. Isto evita que cancelamento seja usado como forma de duplicar geração. Reabertura não será implementada por inferência.
Cancelamento de um destino já substituído, cancelado, confirmado ou com dependências é conflito. Não procurar e cancelar automaticamente a versão mais recente. Cancelar pedido ainda não aplicado é desistência da proposta, não cancelamento dos rascunhos.
## 4. Estados, dependências e acessos
Pedido: preparado/submetido → aprovado ou recusado → aplicado. Desistência antes da aplicação fica registada, sem apagar o pedido. Um pedido aprovado não muda o destino até aplicação transaccional. Fonte/destino/versão/condições alterados invalidam a actualidade da aprovação; aplicação exige nova conferência. Pedido concorrente não substitui silenciosamente outro.
Destino: actual, substituído ou preparação cancelada, com causa e ligação às decisões. Estado de rascunho comercial permanece distinto de confirmação B1, fiscalidade e execução da oficina. Uma função comum de elegibilidade deve ser usada por edição/recarregamento, nova geração a partir da prestação, confirmação/revisão B1 e restantes percursos derivados. Enquanto esse controlo não existir, não activar substituição/cancelamento.
Verificar dependências da prestação e do rascunho, não só ausência de confirmação no par conhecido: outros rascunhos/documentos derivados, versões de preparação documental e revisões em curso podem impedir aplicação. Não reescrever fotografias, propagar alterações silenciosamente ou limpar pendências fiscais. Herança documental para sucessores, se aplicável, exige captura explícita e referência à versão escolhida; não copiar valores privados para auditoria técnica ou prova.
Distribuição **proposta, por confirmar**: Osmane prepara pedidos e aplica os aprovados; Samira consulta, aprova/recusa a versão exacta, sem editar ou aplicar o pedido por consequência da revisão. Auto-aprovação recusada mesmo com ambos os poderes. Revalidar identidade, contexto, pertenças, acesso às origens/destinos e acção no servidor em cada pedido e dentro da transacção.
Propor direitos de unidade próprios de consulta, pedido, revisão e aplicação de correcção/cancelamento. Não derivar de `workshop_billing.prepare/review/generate`, de custos ou do nome da conta. Nomes técnicos, contas e concessões exactas serão apresentados após confirmar o desenho, separadamente de qualquer migração ou instalação. Nenhuma concessão decorre deste documento.
## 5. Exemplo sintético proposto — não aprovado
Reutilizar referências verificadas do exemplo de 105 MIN e 2 UN, sem nova captura. Original: trabalho 1,75 H × 200 = 350; material 2 × 75 = 150; soma comercial 500 MZN sem imposto.
Proposta de correcção de preço para futura prova: trabalho mantém 200 MZN/H; material passa a **80 MZN/UN**, com fonte sintética sucessora explícita `WORKSHOP-SALES-SYNTHETIC-v2`. Novo material 160, soma 510; diferença comercial +10. Não é desvio de custo, margem, total fiscal ou quantia exigível. Original 500 continua intacto no histórico; sucessor 510 só fica actual após revisão/aplicação exactas.
Preço desconhecido impede aplicar; zero explícito exige revisão e não significa desconhecido. Aritmética exacta, respeitando contrato 16/8 das entradas e recusando representações incompatíveis sem arredondamento. Fiscalidade, moeda funcional e regras contabilísticas continuam pendentes; nenhuma aprovação comercial as resolve.
Cancelamento é cenário separado: seleccionar o destino actual elegível e aplicar decisão distinta; preservar 500/510 como história, não substituir montantes por zero. Não executar cenários sucessivos presumindo que cancelamento reabre a origem.
## 6. Provas e critérios para futura implementação
- Original 500 preservado, proposta/revisão distintas, sucessor 510 rastreável e único destino actual, sem novo consumo ou utilização duplicada.
- Recusa, desistência e proposta sucessora mantêm história; aprovação de versão anterior não se transfere. Cliente/moeda/quantidade/origem fora do recorte recusados.
- Cancelamento preserva origem/destino e bloqueia cobrança/reabertura automáticas; repetição não duplica estado ou liberta recursos.
- Destino confirmado B1, com documento derivado, já substituído/cancelado, dependência nova ou fonte técnica obsoleta não pode ser alterado/aplicado. Fotografias e decisões anteriores intocadas.
- Percursos alternativos de A/B1 não contornam o estado; não se retira `ServiceInvoicing::guard()` ou outra guarda para demonstrar o circuito.
- Contexto cruzado e permissão revogada recusados no pedido seguinte, incluindo replay; revisora não ganha verificação técnica, preparação ou aplicação implícitas.
- Idempotência por contexto/actor/acção/conteúdo, conflito de chave, recuperação após resposta perdida, rollback completo de destino/estado/auditoria/outbox e duas transacções sobrepostas: correcção versus correcção, correcção versus cancelamento, aplicação versus confirmação B1/dependência nova.
- Regressões relevantes de A/B1/oficina/conversão, build e prova exclusivamente sintética em `sgf_tempo_test`; sessões/cache isolados, CSRF activo e limitações visuais registadas sem aceitação presumida.
## 7. Decisões novas necessárias
1. Confirmar substituição imutável por novo par de rascunhos, em vez de edição do par já gerado.
2. Confirmar limites iniciais: mesmo cliente/moeda/origens/quantidades; corrigir preço, fonte, descrição e serviço compatível. Alterações técnicas/clientes/volume e repartição parcial ficam de fora.
3. Confirmar cancelamento sem libertação de origens e sem reabertura neste recorte.
4. Confirmar Osmane prepara/aplica e Samira revê por identidade distinta, com direitos próprios.
5. Confirmar ou substituir o exemplo de material 80 MZN/UN e soma 510, exclusivamente sintético, sem imposto.
Depois destas decisões, a implementação ainda requer autorização técnica delimitada. Não existe ordem de migração, concessão, uso real, emissão, stock, recebimento, lançamento, fecho ou publicação nesta etapa.
## 8. Validação das decisões pelo utilizador
Em 28/10/2026, o utilizador respondeu «valido» às cinco opções apresentadas. Ficam confirmadas: substituição imutável por novo par de rascunhos, preservando os originais; mesmo cliente/moeda/origens/quantidades, com correcções comerciais delimitadas na secção 7; cancelamento sem libertação de origens ou reabertura automática; Osmane prepara/aplica e Samira revê por identidade distinta, sem novos direitos reais por consequência; exemplo exclusivamente sintético de material a 80 MZN/UN, mantendo trabalho a 200 MZN/H, soma 510 MZN sem imposto versus original 500.
As referências anteriores a estas decisões por confirmar passam a históricas. Esta validação aprova o desenho e os pressupostos do ensaio, não uma proposta ou decisão no sistema. Permanecem separadas a autorização técnica de implementação, qualquer migração/concessão em desenvolvimento, demonstração, aceitação da futura entrega e checkpoint. Preservar `3932b94`, `30029b9`, `d071bc0`, anteriores e todas as alterações posteriores. Sem alteração de código ou retirada de bloqueios nesta etapa.
+2
View File
@@ -1,5 +1,7 @@
# Oficina → rascunho de facturação — especificação para decisão
Evolução apenas proposta: [correcção e cancelamento controlados](WORKSHOP_BILLING_CORRECTION_SCOPE.md), em etapa documental separada. Não altera a entrega aceite ou habilita edição dos destinos gerados.
**Autorização técnica posterior de 28/10/2026:** o utilizador autorizou implementar e demonstrar `WORKSHOP-BILLING-DRAFT-SIM-v1` exclusivamente em `sgf_tempo_test`, sem aplicar migrações/concessões em desenvolvimento, activar uso real ou publicar. A autorização substitui apenas as restrições anteriores de etapa documental; decisões comerciais e limites permanecem. Ver [entrega e estado das provas](WORKSHOP_BILLING_DELIVERY.md). Não presume aceitação ou checkpoint.
28/10/2026. Próximo recorte proposto: `WORKSHOP-BILLING-DRAFT-SIM-v1`. Nesta etapa **apenas documentação**: sem implementação, migrações, concessões, alteração de operações reais ou publicação. Preservar `04870f0` (orçamento aceite com limitação de capturas), `090b7da` (página inicial/menu com revisão visual pendente) e anteriores. Sem reabrir as respectivas aprovações ou transformar provas pendentes em realizadas.
+16
View File
@@ -0,0 +1,16 @@
{
"version": "SGF-BUDGET-WORKSPACE-SIM-v1",
"url": "http:\/\/127.0.0.1:8003",
"isolated_database": "budget_core_sgf_4abb58d1cabf",
"restricted_role": true,
"synthetic_only": true,
"counts": {
"users": 4,
"cb_budgets": 0,
"cb_budget_entries": 0,
"cb_budget_consumptions": 0,
"cb_ui_commands": 0
},
"core_sha256": "1349d1ab4ab58a83c9abbf43549ebe7e4ed77717ed5463a2defac748cf7b9a31",
"checked_at": "2026-10-29T11:43:33+00:00"
}
+85
View File
@@ -0,0 +1,85 @@
{
"version": "SGF-BUDGET-WORKSPACE-SIM-v1",
"checked_at": "2026-10-29T11:38:41.903Z",
"passed": true,
"core_sha256": "1349d1ab4ab58a83c9abbf43549ebe7e4ed77717ed5463a2defac748cf7b9a31",
"checks": [
"four_real_sessions_and_empty_initial_demo",
"allocation_distinct_approval",
"same_command_recovery",
"changed_key_conflict",
"reservation_award_commit_equivalent",
"insufficient_balance",
"committed_release_refused",
"decimal_precision_rejected",
"failed_consumption_rollback_and_recovery",
"single_consumption_per_reference",
"pending_reinforcement_no_effect",
"distinct_reinforcement_review",
"release_once_no_reopening",
"foreign_origin_refused",
"stale_context_refused",
"csrf_enabled",
"reserved_unit_refused",
"reader_cannot_write"
],
"snapshots": [
{
"step": "reserve",
"balance": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 30000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 70000
}
},
{
"step": "award",
"balance": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 35000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 65000
}
},
{
"step": "commit",
"balance": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 35000,
"consumed_minor": 0,
"available_minor": 65000
}
},
{
"step": "consume",
"balance": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 25000,
"consumed_minor": 10000,
"available_minor": 65000
}
},
{
"step": "reinforcement",
"balance": {
"approved_total_minor": 120000,
"reinforced_minor": 20000,
"reserved_minor": 0,
"committed_minor": 25000,
"consumed_minor": 10000,
"available_minor": 85000
}
}
],
"visual_review": false,
"team_demo_changed": false
}
+14
View File
@@ -0,0 +1,14 @@
{
"host": "compras",
"isolated": true,
"restricted_role": true,
"counts": {
"sources": 0,
"proposals": 0,
"obligations": 0,
"events": 0,
"outbox": 0
},
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
"checked_at": "2026-10-29T12:50:46+00:00"
}
+14
View File
@@ -0,0 +1,14 @@
{
"host": "sgf",
"isolated": true,
"restricted_role": true,
"counts": {
"sources": 0,
"proposals": 0,
"obligations": 0,
"events": 0,
"outbox": 0
},
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
"checked_at": "2026-10-29T12:50:46+00:00"
}
+34
View File
@@ -0,0 +1,34 @@
{
"version": "COMMON-PAYABLE-SOURCE-SIM-v1",
"passed": true,
"checked_at": "2026-10-29T12:47:50.740Z",
"team_budget_preserved": true,
"hosts": [
{
"host": "compras",
"passed": true,
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
"source_revision": "4a34d4256cf35402f407d4cb8d1f4a26441f8aa2be1a7b30312fdb2d631a22eb",
"recognised_obligations": 0,
"checks": [
"previous_settlement_blocks_recognition",
"history_unchanged",
"no_full_unpaid_import"
],
"visual_review": false
},
{
"host": "sgf",
"passed": true,
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
"source_revision": "4a34d4256cf35402f407d4cb8d1f4a26441f8aa2be1a7b30312fdb2d631a22eb",
"recognised_obligations": 0,
"checks": [
"previous_settlement_blocks_recognition",
"history_unchanged",
"no_full_unpaid_import"
],
"visual_review": false
}
]
}
+154
View File
@@ -0,0 +1,154 @@
{
"version": "COMMON-PAYABLE-SOURCE-SIM-v1",
"passed": true,
"checked_at": "2026-10-29T12:47:37.052Z",
"team_budget_preserved": true,
"hosts": [
{
"host": "compras",
"passed": true,
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
"source_revision": "9ca4ac29a4720d6c4885101fd1485c58fb2dab3dcc2d5d975cb919679df58f54",
"principal_minor": 1160000,
"due_on": null,
"days_overdue": null,
"races": [
{
"waits": 2,
"results": [
{
"status": 200,
"state": "prepared",
"pid": 6472
},
{
"status": 200,
"state": "prepared",
"pid": 14488
}
]
},
{
"waits": 2,
"results": [
{
"status": 403,
"pid": 18912
},
{
"status": 403,
"pid": 3488
}
]
},
{
"waits": 2,
"results": [
{
"status": 200,
"state": "recognised",
"pid": 8112
},
{
"status": 200,
"state": "recognised",
"pid": 804
}
]
}
],
"checks": [
"genuine_conferred_invoice",
"no_duplicate_new_keys",
"conflicting_payload",
"distinct_person",
"incomplete_source_pending",
"source_revision_conflict",
"revocation_while_waiting",
"event_fault_atomic_rollback",
"retry_after_failure",
"unknown_due_even_future",
"foreign_scope_refused",
"no_stock_budget_payment_effect",
"existing_settlement_history_refused_and_preserved",
"durable_events_outbox",
"read_only_ui",
"csrf_active"
],
"visual_review": false
},
{
"host": "sgf",
"passed": true,
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
"source_revision": "9ca4ac29a4720d6c4885101fd1485c58fb2dab3dcc2d5d975cb919679df58f54",
"principal_minor": 1160000,
"due_on": null,
"days_overdue": null,
"races": [
{
"waits": 2,
"results": [
{
"status": 200,
"state": "prepared",
"pid": 6892
},
{
"status": 200,
"state": "prepared",
"pid": 11424
}
]
},
{
"waits": 2,
"results": [
{
"status": 403,
"pid": 804
},
{
"status": 403,
"pid": 20804
}
]
},
{
"waits": 2,
"results": [
{
"status": 200,
"state": "recognised",
"pid": 20576
},
{
"status": 200,
"state": "recognised",
"pid": 11332
}
]
}
],
"checks": [
"genuine_conferred_invoice",
"no_duplicate_new_keys",
"conflicting_payload",
"distinct_person",
"incomplete_source_pending",
"source_revision_conflict",
"revocation_while_waiting",
"event_fault_atomic_rollback",
"retry_after_failure",
"unknown_due_even_future",
"foreign_scope_refused",
"no_stock_budget_payment_effect",
"existing_settlement_history_refused_and_preserved",
"durable_events_outbox",
"read_only_ui",
"csrf_active"
],
"visual_review": false
}
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"demo": "SGF-PRODUCT-DEMO-v1",
"checked_at": "2026-10-29T08:50:35.350Z",
"type": "HTTP integration, not visual acceptance",
"business_operations_changed": false,
"results": [
{
"role": "preparador",
"modules": [
"workshop",
"workshop-billing",
"workshop-corrections",
"direct-costs",
"industrial-costs",
"industrial-budgets",
"service-invoices",
"service-receivables"
],
"unauthorised_unit_refused": true
},
{
"role": "validador",
"modules": [
"workshop",
"workshop-billing",
"workshop-corrections",
"direct-costs",
"industrial-costs",
"industrial-budgets",
"service-invoices",
"service-receivables"
],
"unauthorised_unit_refused": true
},
{
"role": "tecnico",
"modules": [
"workshop"
],
"unauthorised_unit_refused": true,
"unauthorised_module_refused": "industrial-costs"
},
{
"role": "verificador",
"modules": [
"workshop"
],
"unauthorised_unit_refused": true,
"unauthorised_module_refused": "service-receivables"
}
]
}
@@ -0,0 +1,16 @@
{
"policy": "WORKSHOP-BILLING-CORRECTION-SIM-v1",
"database": "sgf_tempo_test",
"processes_per_case": 2,
"checks": {
"request_replay_one_version": true,
"replacement_vs_cancellation_one_request_successor": true,
"review_replay_one_decision": true,
"apply_replay_one_successor_pair": true,
"competing_reviews_one_decision": true,
"cancellation_blocks_overlapping_b1": true
},
"source_claims_unchanged": 2,
"applications": 2,
"b1_created": false
}
@@ -0,0 +1,10 @@
{
"environment": "browser-testing",
"database": "sgf_tempo_test",
"role": "sgf_test_runner",
"database_port": 55432,
"session_driver": "file",
"session_cookie": "sgf_browser_proof",
"cache_store": "file",
"synthetic_only": true
}
@@ -0,0 +1,15 @@
{
"policy":"WORKSHOP-BILLING-CORRECTION-SIM-v1",
"date":"2026-10-28",
"database":"sgf_tempo_test",
"full_regression":{"tests":183,"assertions":3209,"failures":0,"errors":0},
"new_correction_cases":8,
"overlapping_process_cases":6,
"build":{"vite":"8.3.5","passed":true,"dependencies_changed":false},
"manual_browser":{"port":8002,"fixture_prepared":true,"environment_checked_by_http":true,"user_path_completed":false,"screenshots_received":false,"automatic_edge_retried":false},
"development_migration_130000_applied":false,
"new_real_grants_applied":false,
"real_corrections_guard_refused":true,
"user_acceptance":false,
"closing_checkpoint_created":false
}
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/Payables.php';
require_once __DIR__.'/src/Workspace.php';
+17
View File
@@ -0,0 +1,17 @@
<?php
// Explicitly called by disposable/new QA setup only, never registered in host migrations.
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
$db=DB::connection();
if($db->getDriverName()!=='pgsql'||!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$db->getDatabaseName())throw new LogicException('Payables schema refused outside isolated QA.');
$scope=function(Blueprint $t){foreach(['tenant_id','company_id','unit_id'] as $k)$t->unsignedBigInteger($k);};
Schema::create('pa_access',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('user_id');$t->string('person');$t->string('action');$t->unique(['tenant_id','company_id','unit_id','user_id','action']);});
Schema::create('pa_sources',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->string('source_key',64)->unique();$t->unsignedBigInteger('local_source_id');$t->timestamp('created_at');});
Schema::create('pa_proposals',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->string('source_revision',64);$t->integer('capture_version');$t->jsonb('snapshot');$t->jsonb('missing');$t->unsignedBigInteger('prepared_by');$t->string('prepared_person');$t->text('reason');$t->timestamp('created_at');$t->unique(['source_id','capture_version']);});
Schema::create('pa_obligations',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->unique()->constrained('pa_sources');$t->foreignId('proposal_id')->constrained('pa_proposals');$t->string('source_revision',64);$t->bigInteger('principal_minor');$t->string('currency',3);$t->date('due_on')->nullable();$t->unsignedBigInteger('recognised_by');$t->string('recognised_person');$t->text('reason');$t->timestamp('created_at');});
Schema::create('pa_commands',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('actor_id');$t->uuid('command_key');$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('pa_events',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->unsignedBigInteger('actor_id');$t->string('kind');$t->jsonb('payload');$t->timestamp('created_at');});
Schema::create('pa_outbox',function(Blueprint $t){$t->foreignId('event_id')->primary()->constrained('pa_events');$t->string('state');});
DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$");
foreach(['pa_sources','pa_proposals','pa_obligations','pa_events','pa_outbox','pa_commands'] as $t)DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$t} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
+221
View File
@@ -0,0 +1,221 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\ConnectionInterface;
/** One payable per document identity. No session, money rounding, settlement or budget effects. */
final class Payables
{
public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1';
public function __construct(private ConnectionInterface $db, private int $actor, private string $person, private array $scope,
private \Closure $authorize, private \Closure $loadSource) {}
public static function canonical(array $data): string
{
$sort = function ($v) use (&$sort) {
if (! is_array($v)) {
return $v;
} if (! array_is_list($v)) {
ksort($v);
}
return array_map($sort, $v);
};
return json_encode($sort($data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
public static function identity(array $s): string
{
return hash('sha256', self::canonical(array_intersect_key($s, array_flip(['instance', 'company', 'document']))));
}
public static function revision(array $s): string
{
unset($s['payment_count'],$s['local_id']);
return hash('sha256', self::canonical($s));
}
public static function missing(array $s): array
{
$m = [];
foreach (['instance', 'company', 'document', 'number', 'supplier_id', 'supplier_name', 'order_id', 'validated_by', 'validated_at', 'validation_audit_id'] as $k) {
if (empty($s[$k])) {
$m[] = 'Origem/decisão em falta: '.$k;
}
}
if (($s['status'] ?? null) !== 'validated') {
$m[] = 'Factura ainda não conferida.';
}
if (($s['currency'] ?? null) !== 'MZN') {
$m[] = 'Moeda ausente ou incompatível; apenas MZN neste recorte.';
}
foreach (['subtotal_minor', 'tax_minor', 'total_minor'] as $k) {
if (! isset($s[$k]) || ! is_int($s[$k]) || $s[$k] < 0) {
$m[] = 'Montante sustentado em falta: '.$k;
}
}
if (isset($s['subtotal_minor'],$s['tax_minor'],$s['total_minor']) && $s['total_minor'] !== $s['subtotal_minor'] + $s['tax_minor']) {
$m[] = 'Componentes documentais incompatíveis; sem recálculo.';
}
if (empty($s['lines'])) {
$m[] = 'Linhas conferidas em falta.';
}
if (! array_key_exists('payment_count', $s) || $s['payment_count'] !== 0) {
$m[] = 'Histórico de pagamentos existente ou desconhecido; migração fora do âmbito.';
}
return $m;
}
private function q(string $table)
{
return $this->db->table('pa_'.$table)->where($this->scope);
}
private function event(string $kind, int $source, array $data): void
{
$id = $this->db->table('pa_events')->insertGetId($this->scope + ['source_id' => $source, 'actor_id' => $this->actor, 'kind' => $kind, 'payload' => self::canonical($data), 'created_at' => date('Y-m-d H:i:s')]);
$this->db->table('pa_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_settlement_or_accounting']);
}
private function source(int $local): array
{
$s = ($this->loadSource)($local);
foreach (['instance', 'company', 'document'] as $key) {
if (empty($s[$key])) {
throw new \DomainException('Identidade de origem insuficiente.');
}
}
return $s;
}
private function locked(array $s): object
{
$key = self::identity($s);
$this->db->table('pa_sources')->insertOrIgnore($this->scope + ['source_key' => $key, 'local_source_id' => $s['local_id'], 'created_at' => date('Y-m-d H:i:s')]);
$row = $this->db->table('pa_sources')->where('source_key', $key)->lockForUpdate()->first();
if (! $row) {
throw new \DomainException('Identidade documental comercial já associada; revisão necessária.');
}
foreach ($this->scope as $k => $v) {
if ((int) $row->$k !== $v) {
throw new \DomainException('Origem já associada a outro contexto.');
}
}
return $row;
}
public function command(string $action, int $id, string $reason, string $key): array
{
if (! in_array($action, ['prepare', 'validate'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) {
throw new \InvalidArgumentException('Acção, chave ou fundamento inválidos.');
}
return $this->db->transaction(function () use ($action, $id, $reason, $key) {
($this->authorize)($action);
$hash = hash('sha256', self::canonical([$action, $id, $reason]));
$old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
if ($old) {
if ($old->request_hash !== $hash) {
throw new \DomainException('Chave usada com conteúdo diferente.');
}
// Even replay rechecks the current source/revision/payment eligibility.
$result = json_decode($old->result, true, 32, JSON_THROW_ON_ERROR);
$local = $result['local_source_id'];
$s = $this->source($local);
$row = $this->locked($s);
$material = self::revision($s);
if (($result['source_revision'] ?? null) !== $material || self::missing($s) !== ($result['missing'] ?? [])) {
return $this->conflict($row, $local, 'A origem/eligibilidade mudou após o comando; original preservado.');
}
return $result + ['replayed' => true];
}
if ($action === 'prepare') {
$s = $this->source($id);
$row = $this->locked($s);
$revision = self::revision($s);
$missing = self::missing($s);
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
if ($ob) {
$result = $ob->source_revision === $revision && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento alterado ou pagamentos associados; nenhuma dívida adicional.');
} else {
$candidate = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing) {
$pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($s), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
$this->event('payable.proposed', $row->id, ['proposal_id' => $pid, 'source_revision' => $revision, 'missing' => $missing]);
} else {
$pid = $candidate->id;
}
$result = ['state' => $missing === [] ? 'prepared' : 'pending_source', 'proposal_id' => $pid, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => $missing];
}
} else {
$p = $this->q('proposals')->where('id', $id)->first();
if (! $p) {
throw new \DomainException('Proposta fora do âmbito.');
}
if ((int) $p->prepared_by === $this->actor || $p->prepared_person === $this->person) {
throw new \DomainException('Validação exige pessoa distinta da preparação.');
}
$row = $this->q('sources')->where('id', $p->source_id)->lockForUpdate()->first();
$local = (int) $row->local_source_id;
$s = $this->source($local);
$revision = self::revision($s);
$missing = self::missing($s);
$latest = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== []) {
$result = $this->conflict($row, $local, 'Revisão/decisão/pagamentos incompatíveis ou informação insuficiente.');
} elseif ($ob) {
$result = ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
} else {
$oid = $this->db->table('pa_obligations')->insertGetId($this->scope + ['source_id' => $row->id, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'currency' => 'MZN', 'due_on' => null, 'recognised_by' => $this->actor, 'recognised_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
$this->event('payable.recognised', $row->id, ['obligation_id' => $oid, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'due_on' => null]);
$result = ['state' => 'recognised', 'obligation_id' => $oid, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
}
}
$this->db->table('pa_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => self::canonical($result), 'created_at' => date('Y-m-d H:i:s')]);
return $result + ['replayed' => false];
});
}
private function conflict(object $row, int $local, string $reason): array
{
$this->event('payable.source_conflict', $row->id, ['reason' => $reason, 'observed_revision' => self::revision($this->source($local))]);
return ['state' => 'conflict', 'local_source_id' => $local, 'reason' => $reason];
}
public function listing(array $localSources): array
{
return $this->db->transaction(function () use ($localSources) {
($this->authorize)('view');
$documents = [];
foreach ($localSources as $id) {
$s = $this->source($id);
$documents[] = $s + ['revision' => self::revision($s), 'missing' => self::missing($s)];
}
$current = [];
foreach ($documents as $doc) {
$current[self::identity($doc)] = $doc;
}
$obs = $this->q('obligations')->orderBy('id')->get()->map(function ($o) use ($current) {
$origin = $this->q('sources')->where('id', $o->source_id)->first();
$doc = $current[$origin->source_key] ?? null;
// Additional UI-derived revision/missing fields are not part of the material source hash.
$compatible = $doc && $doc['revision'] === $o->source_revision && $doc['missing'] === [];
return (array) $o + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $o->principal_minor : null, 'source_conflict' => ! $compatible, 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'], $compatible ? [] : ['Origem/eligibilidade divergente; saldo não presumido.'])];
})->all();
return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $obs, 'proposals' => $this->q('proposals')->orderBy('id')->get(), 'events' => $this->q('events')->orderBy('id')->get(), 'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor];
});
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Tempo\Finance\Payables;
final class Workspace
{
public static function render(array $data, array $settings): string
{
ob_start();
require __DIR__.'/../ui/page.php';
return ob_get_clean();
}
}
@@ -0,0 +1,50 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\CostCenter;
use App\Models\Department;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\Supplier;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Synthetic fixture: callable only on in-memory SQLite or the dedicated PostgreSQL test database. */
function payableReceiptFixture(string $quantity = '1.000', string $purchaseType = 'consumable'): array
{
$connection = DB::connection();
$allowed = ($connection->getDriverName() === 'sqlite' && $connection->getDatabaseName() === ':memory:') ||
($connection->getDriverName() === 'pgsql' && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $connection->getDatabaseName()));
if (! app()->environment('testing') || ! $allowed) {
throw new LogicException('Fixtures de recepção proibidas fora de uma base de testes isolada.');
}
$tenant = Tenant::query()->create(['name' => 'Horizonte Sintética — Obrigações']);
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => 'Operações']);
$center = CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'OPS', 'name' => 'Operações']);
$issuer = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]);
$receiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
$otherReceiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
auth()->guard()->setUser($receiver);
$r = Requisition::query()->create(['tenant_id' => $tenant->id, 'requester_id' => $issuer->id, 'department_id' => $department->id, 'cost_center_id' => $center->id, 'number' => 'REQ-TEST-'.Str::random(10), 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Fixture de ordem já emitida para testes de recepção.', 'status' => 'approved', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000]);
$requested = $r->items()->create(['tenant_id' => $tenant->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'quantity' => $quantity, 'unit' => 'cx', 'unit_price_minor' => 1000000, 'tax_rate' => '16', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000]);
$supplier = Supplier::query()->create(['tenant_id' => $tenant->id, 'created_by' => $issuer->id, 'name' => 'Fornecedor Alfa Sintético', 'nuit' => '123456789', 'contact' => 'Contacto de teste', 'email' => 'fixture@example.invalid', 'phone' => '000000000', 'address' => 'Morada fictícia', 'category' => 'Material', 'beneficiaries' => 'Beneficiário fictício', 'document_references' => 'Documento fictício', 'diligence_notes' => 'Somente para a base de testes.', 'status' => 'active', 'next_review' => now()->addYear()]);
$quote = Quotation::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'supplier_id' => $supplier->id, 'created_by' => $issuer->id, 'reference' => 'PROP-TEST', 'delivery_days' => 5, 'valid_until' => now()->addWeek(), 'payment_terms' => '30 dias', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000, 'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10]]);
$award = PurchaseAward::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'quotation_id' => $quote->id, 'created_by' => $issuer->id, 'justification' => 'Adjudicação de fixture, não repetir o fluxo anterior.', 'delivery_date' => now()->addWeek(), 'delivery_location' => 'Armazém fictício', 'status' => 'approved', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000, 'approval_snapshot' => [], 'decision_matrix' => [], 'comparison_hash' => str_repeat('0', 64)]);
$order = PurchaseOrder::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'purchase_award_id' => $award->id, 'supplier_id' => $supplier->id, 'issued_by' => $issuer->id, 'number' => 'OC-TEST-'.Str::random(10), 'status' => 'issued', 'currency' => 'MZN', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000, 'issued_at' => now(), 'snapshot' => ['supplier' => $supplier->only(['name', 'nuit']), 'company' => ['name' => $tenant->name], 'development_only' => true, 'budget_checked' => false]]);
$line = $order->items()->create(['tenant_id' => $tenant->id, 'requisition_item_id' => $requested->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'unit' => 'cx', 'quantity' => $quantity, 'unit_price_minor' => 1000000, 'tax_rate' => '16', 'subtotal_minor' => 1000000, 'tax_minor' => 160000, 'total_minor' => 1160000]);
$warehouse = Warehouse::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ARM-TEST', 'name' => 'Armazém fictício']);
$stock = StockItem::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ART-TEST', 'name' => 'Artigo de stock fictício', 'unit' => 'cx']);
return compact('tenant', 'department', 'issuer', 'receiver', 'otherReceiver', 'r', 'order', 'line', 'warehouse', 'stock');
}
function payableReceiptPayload(array $fixture, string $quantity = '0.600'): array
{
return ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-'.Str::random(10), 'received_on' => now()->format('Y-m-d'), 'warehouse_id' => $fixture['warehouse']->id, 'items' => [['purchase_order_item_id' => $fixture['line']->id, 'received_quantity' => $quantity, 'accepted_quantity' => $quantity, 'quality_status' => 'compliant', 'stock_item_id' => $fixture['stock']->id, 'notes' => null]]];
}
+56
View File
@@ -0,0 +1,56 @@
<?php
// Called only after a fresh, restricted QA database has been provisioned.
$mode=$argv[1]??'';$root=$mode==='compras'?'C:/laragon/www/compras':'C:/laragon/www/sgf-tempo';
require $root.'/vendor/autoload.php';$app=require $root.'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
$db=DB::connection();$name=$db->getDatabaseName();
if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$name)||$db->selectOne('select current_database() as d')->d!==$name||Schema::hasTable('users'))throw new LogicException('Fresh QA only.');
$file=getenv('PAYABLE_ACCESS_FILE');$bundle=getenv('PAYABLE_BUNDLE');
if($mode==='compras'){
\Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);
require __DIR__.'/../qa-schema.php';require __DIR__.'/ReceiptFixture.php';
$f=payableReceiptFixture();
app(\App\Services\ReceiptWorkflow::class)->confirm($f['receiver'],$f['order'],payableReceiptPayload($f,'1.000'));
$p=\App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'finance']);
$v=\App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'finance']);
$q=\App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'admin']);
auth()->guard()->setUser($p);
\App\Models\Budget::query()->create(['tenant_id'=>$f['tenant']->id,'cost_center_id'=>$f['r']->cost_center_id,'year'=>now()->year,'amount_minor'=>2000000,'includes_tax'=>true,'approved_by'=>$v->id,'created_by'=>$p->id,'reason'=>'Only synthetic source preparation']);
DB::transaction(function()use($p,$f){app(\App\Services\BudgetWorkflow::class)->record($p,$f['r'],'reserve',1160000,1000000);app(\App\Services\BudgetWorkflow::class)->record($p,$f['r'],'commit',1160000,1000000);});
$invoice=app(\App\Services\InvoiceWorkflow::class)->register($p,$f['order'],['number'=>'FT-SIM-11600','issued_on'=>now()->format('Y-m-d'),'subtotal'=>'10000.00','tax'=>'1600.00','total'=>'11600.00','items'=>[['purchase_order_item_id'=>$f['line']->id,'quantity'=>'1.000','unit'=>'cx','unit_price'=>'10000.00','tax_rate'=>'16']]]);
auth()->guard()->setUser($v);app(\App\Services\InvoiceWorkflow::class)->validate($v,$invoice);
$scope=['tenant_id'=>$f['tenant']->id,'company_id'=>0,'unit_id'=>0];
auth()->guard()->setUser($p);$source=app(\App\Services\SupplierPayables::class)->document($p,$invoice->id);
if($source['total_minor']!==1160000||$source['tax_minor']!==160000||$source['status']!=='validated'||!$source['validation_audit_id'])throw new RuntimeException('Source scenario mismatch.');
file_put_contents($bundle,\Tempo\Finance\Payables\Payables::canonical($source),LOCK_EX);
$sourceId=$invoice->id;
}else{
foreach(['0001_01_01_000000_create_users_table.php','2026_10_25_000000_create_identity_foundation.php','2026_10_25_010000_add_identity_workflow.php'] as $m)(require $root.'/database/migrations/'.$m)->up();
require __DIR__.'/../qa-schema.php';
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 ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR (permission IN ('unit.view','unit.edit','supplier_payables.view') AND unit_id IS NOT NULL))");
$now=now();$tenant=DB::table('tenants')->insertGetId(['name'=>'Tenant sintético','created_at'=>$now,'updated_at'=>$now]);
DB::table('companies')->insert(['tenant_id'=>$tenant,'name'=>'Empresa não autorizada','created_at'=>$now,'updated_at'=>$now]);
$company=DB::table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Horizonte Sintética — Obrigações','created_at'=>$now,'updated_at'=>$now]);
DB::table('units')->insert(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Unidade reservada','created_at'=>$now,'updated_at'=>$now]);
$unit=DB::table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Unidade de obrigações','created_at'=>$now,'updated_at'=>$now]);
$scope=['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];
$p=\App\Models\User::create(['name'=>'Preparador','email'=>'seed-p@example.test','password'=>bin2hex(random_bytes(20))]);
$v=\App\Models\User::create(['name'=>'Validador','email'=>'seed-v@example.test','password'=>bin2hex(random_bytes(20))]);
$q=\App\Models\User::create(['name'=>'Consulta','email'=>'seed-q@example.test','password'=>bin2hex(random_bytes(20))]);
foreach([$p,$v,$q] as $u){DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company]);foreach(['company.view'=>null,'unit.view'=>$unit,'supplier_payables.view'=>$unit] as $right=>$which)DB::table('permission_grants')->insert(['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$which,'user_id'=>$u->id,'permission'=>$right,'created_at'=>$now,'updated_at'=>$now]);}
Schema::create('pa_imported_sources',function(Blueprint $t){$t->id();foreach(['tenant_id','company_id','unit_id'] as $k)$t->unsignedBigInteger($k);$t->jsonb('payload');$t->string('receipt_hash',64);});
$source=json_decode(file_get_contents($bundle),true,64,JSON_THROW_ON_ERROR);
if(\Tempo\Finance\Payables\Payables::missing($source)!==[])throw new RuntimeException('Conferred Compras source required.');
$sourceId=DB::table('pa_imported_sources')->insertGetId($scope+['payload'=>\Tempo\Finance\Payables\Payables::canonical($source),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($source))]);
}
$accounts=[];
foreach(['preparacao'=>[$p,['view','prepare']],'validacao'=>[$v,['view','validate']],'consulta'=>[$q,['view']]] as $role=>[$user,$rights]){
$password=bin2hex(random_bytes(20));$user->forceFill(['name'=>'Obrigações — '.$role,'email'=>$role.'-obrigacoes@example.test','password'=>$password]);$user->save();
foreach($rights as $right)DB::table('pa_access')->insert($scope+['user_id'=>$user->id,'person'=>'synthetic-person-'.$role,'action'=>$right]);
$accounts[$role]=['id'=>$user->id,'email'=>$user->email,'password'=>$password];
}
file_put_contents($file,json_encode(['mode'=>$mode,'scope'=>$scope,'source_id'=>$sourceId,'accounts'=>$accounts],JSON_THROW_ON_ERROR),LOCK_EX);
echo "Synthetic conferred-source scenario prepared; obligations remain unrecognised for team testing.\n";
+21
View File
@@ -0,0 +1,21 @@
<?php $e=fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $money=fn($v,$currency='MZN')=>number_format(intdiv((int)$v,100),0,'.',' ').'.'.str_pad((string)abs((int)$v%100),2,'0',STR_PAD_LEFT).' '.$e($currency); ?>
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Obrigações a fornecedores — ensaio</title>
<style>body{font:16px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:1100px;margin:auto;padding:24px}header,section{background:white;padding:24px;margin:18px 0;border:1px solid #d5e0dc;border-radius:12px}h1{font-size:28px}h2{font-size:20px}.notice{padding:16px;background:#fff2cc}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}label{display:block;margin:12px 0}select,textarea,button{font:inherit;padding:10px;max-width:100%}textarea{display:block;width:90%}button{background:#125a4b;color:white;border:0;border-radius:6px;cursor:pointer}button:disabled{opacity:.5}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}.scroll{overflow:auto}.amount{font-size:26px;font-weight:bold}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#125a4b}</style></head><body><main>
<header><a href="/">← Voltar à aplicação</a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Obrigações a fornecedores</h1><p><?= $e($settings['organisation']) ?></p><p class="notice">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso só reconhece o principal.</p><p>Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.</p></header>
<p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
<section><h2>Facturas de origem</h2><div class="scroll"><table><thead><tr><th>Documento</th><th>Fornecedor</th><th>Base</th><th>Imposto documental</th><th>Total</th><th>Conferência / revisão</th></tr></thead><tbody>
<?php foreach($data['documents'] as $d): ?><tr><td><?= $e($d['number']??'Indeterminado') ?></td><td><?= $e($d['supplier_name']??'Indeterminado') ?></td><td><?= isset($d['subtotal_minor'])?$money($d['subtotal_minor']):'Indeterminado' ?></td><td><?= isset($d['tax_minor'])?$money($d['tax_minor']):'Indeterminado' ?></td><td><?= isset($d['total_minor'])?$money($d['total_minor']):'Indeterminado' ?></td><td><?= $e($d['status']) ?><details><summary>Origem e pendências</summary><pre><?= $e(json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></td></tr><?php endforeach ?>
</tbody></table></div></section>
<?php if($data['permissions']['prepare']): ?><section><h2>Preparar a obrigação</h2><form data-action="prepare"><label>Factura existente<select name="id"><?php foreach($data['documents'] as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Proposta sintética com base na factura conferida.</textarea></label><p>Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.</p><button>Preparar da origem</button></form></section><?php endif ?>
<section><h2>Propostas e decisões</h2><?php if(!$data['proposals']->count()): ?><p>Ainda não há propostas.</p><?php endif ?>
<?php foreach($data['proposals'] as $p): ?><article><h3>Proposta #<?= $e($p->id) ?> · captura <?= $e($p->capture_version) ?></h3><p>Preparador #<?= $e($p->prepared_by) ?> · <?= $e($p->created_at) ?></p><p><?= $e($p->reason) ?></p><details><summary>Fotografia e pendências</summary><pre><?= $e($p->snapshot) ?></pre><pre><?= $e($p->missing) ?></pre></details>
<?php if($data['permissions']['validate']): ?><form data-action="validate"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Validar revisão exacta e reconhecer</button></form><?php endif ?></article><?php endforeach ?></section>
<section><h2>Obrigações reconhecidas</h2><?php if(!$data['obligations']): ?><p>Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.</p><?php endif ?><div class="grid">
<?php foreach($data['obligations'] as $o): ?><article><h3>Obrigação #<?= $e($o['id']) ?></h3><p class="amount"><?= $money($o['principal_minor']) ?></p><p>Principal reconhecido — vencimento pendente</p><p>Atraso indeterminado · Dias de atraso: sem valor</p><p>Validador #<?= $e($o['recognised_by']) ?> · <?= $e($o['created_at']) ?></p><details><summary>Origem, versão e decisão</summary><pre><?= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></article><?php endforeach ?></div></section>
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
<p>Sem pagamento, cobrança, afectação, stock, novo consumo orçamental ou lançamento automático. A consulta não marca o documento como pago.</p>
</main><script>
const settings=<?= json_encode($settings,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;
document.querySelector('#reload').onclick=()=>location.reload();
for(const form of document.querySelectorAll('form[data-action]'))form.onsubmit=async event=>{event.preventDefault();const body=Object.fromEntries(new FormData(form));body.id=Number(body.id);const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const button=form.querySelector('button');button.disabled=true;document.querySelector('#error').textContent='';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw new Error(d.message??Object.values(d.errors??{}).flat().join(' '));if(d.state==='conflict'){document.querySelector('#error').textContent=d.reason+' Consulte o histórico.';return;}location.reload();}catch(e){document.querySelector('#error').textContent=e.message;}finally{button.disabled=false;}};
</script></body></html>
@@ -0,0 +1,67 @@
import React, {useEffect, useRef, useState} from 'react';
export function budgetMoney(value) {
if (value === null || value === undefined) return 'Indisponível';
const n=BigInt(String(value)), sign=n<0n?'-':'', digits=(n<0n?-n:n).toString().padStart(3,'0');
return `${sign}${digits.slice(0,-2).replace(/\B(?=(\d{3})+(?!\d))/g,' ')}.${digits.slice(-2)} MZN`;
}
const kinds={reserve:'Reserva',award:'Ajuste de adjudicação',commit:'Compromisso',release:'Libertação'};
export default function PurchasingBudgetWorkspace({context,can,api,busy}) {
const [data,setData]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[working,setWorking]=useState(false),[selection,setSelection]=useState('');
const retry=useRef(null);
const activeContext=useRef(context.token);activeContext.current=context.token;
const allowed=r=>can(`purchasing_budget.${r}`,context.unit?.id??null);
const load=async()=>{const token=context.token;const next=await api('/api/purchasing-budget','GET',undefined,token);if(activeContext.current===token)setData(next);};
useEffect(()=>{setData(null);setSelection('');setError('');setNotice('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
const disabled=busy||working;
const budget=data?.budgets.find(b=>String(b.id)===selection)??data?.budgets[0];
const availableOrigins=data?.origins.filter(o=>o.cost_center_id===budget?.cost_center_id)??[];
const canMove=!!budget?.approved_by&&Number(budget.year)===data?.current_year&&availableOrigins.length>0;
async function send(action,input) {
const signature=JSON.stringify([action,input,context.token]);
if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};
setWorking(true);setError('');setNotice('');
try {const result=await api(`/api/purchasing-budget/${action}`,'POST',input,context.token,retry.current.key);if(activeContext.current!==context.token)return;retry.current=null;await load();if(action==='create')setSelection(String(result.id));setNotice(result.replayed?'Resposta recuperada; nenhum efeito duplicado.':'Operação sintética registada. Saldos actualizados pelo núcleo comum.');}
catch(e){setError(e.message);}finally{setWorking(false);}
}
const submit=(action,convert)=>event=>{event.preventDefault();send(action,convert(Object.fromEntries(new FormData(event.currentTarget))));};
const originOptions=availableOrigins.map(o=>{
const movements=data.entries.filter(e=>e.requisition_id===o.id).map(e=>e.kind);
const stage=movements.includes('release')?'Libertada':movements.includes('commit')?'Comprometida':movements.includes('award')?'Adjudicação ajustada':movements.includes('reserve')?'Reservada':'Sem movimentos';
return <option key={o.id} value={o.id}>{o.reference} · {stage} · centro #{o.cost_center_id}</option>;
});
const moneyInput=(name,label,value='')=><label>{label}<input name={name} inputMode="decimal" pattern="[0-9]{1,12}([.][0-9]{1,2})?" defaultValue={value} required placeholder="0.00" /></label>;
return <section><p className="eyebrow">DEMONSTRAÇÃO ORÇAMENTAL</p><h1>Compras — controlo orçamental</h1>
<p className="alert">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Dotações, reservas, compromissos e consumo usam o núcleo comum de Compras. Este percurso não é o orçamento industrial.</p>
<p>Empresa: <strong>{context.company.name}</strong> · Unidade: <strong>{context.unit?.name}</strong> · Moeda: MZN</p>
<p className="help">Prepare a dotação, entre como validador para a aprovar e use a conta de operação para os movimentos. Decisões exigem identidade distinta. Consumo aqui é apenas um efeito orçamental simulado, sem factura, obrigação ou pagamento.</p>
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="notice" role="status">{notice}</p>}
<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar saldos e histórico</button>
{!data?<p role="status">A carregar o percurso…</p>:<>
{allowed('create')&&<section className="panel"><h2>Preparar dotação</h2><form onSubmit={submit('create',d=>({...d,cost_center_id:Number(d.cost_center_id),year:Number(d.year),includes_tax:d.includes_tax==='true'}))}>
<fieldset disabled={disabled}><label>Centro de custo<select name="cost_center_id" required>{data.centres.map(c=><option key={c.id} value={c.id}>{c.code}</option>)}</select></label>
<label>Exercício<input name="year" type="number" min="2026" max="2100" defaultValue={data.current_year} required /></label>
<label>Natureza<select name="kind"><option value="operating">Corrente</option><option value="investment">Investimento</option></select></label>
<label>Base da dotação<select name="includes_tax"><option value="true">Com imposto indicado</option><option value="false">Sem imposto — usar subtotal</option></select></label>
{moneyInput('amount','Dotação inicial (MZN)','1000.00')}<label>Fundamento<textarea name="reason" required minLength={3} defaultValue="Dotação exclusivamente sintética para a equipa." /></label><button>Guardar proposta de dotação</button></fieldset>
</form></section>}
<section className="panel"><h2>Dotações e saldos</h2>{!budget?<p>Não há dotações neste contexto. A conta de preparação pode criar a primeira.</p>:<>
<label>Dotação<select value={String(budget.id)} onChange={e=>setSelection(e.target.value)}>{data.budgets.map(b=><option key={b.id} value={b.id}>#{b.id} · centro #{b.cost_center_id} · {b.year} · {b.includes_tax?'com imposto':'sem imposto'}</option>)}</select></label>
<p>Estado: <strong>{budget.approved_by?'Aprovada':'Pendente de aprovação'}</strong> · Preparada por {data.actors[budget.created_by]??`#${budget.created_by}`} {budget.approved_by&&`· Validada por ${data.actors[budget.approved_by]??`#${budget.approved_by}`}`}</p>
<dl className="home-context">{[['Dotação inicial',budget.amount_minor],['Reforço aprovado',budget.balance.reinforced_minor],['Total aprovado',budget.balance.approved_total_minor],['Reservado',budget.balance.reserved_minor],['Comprometido',budget.balance.committed_minor],['Consumido',budget.balance.consumed_minor],['Disponível',budget.balance.available_minor]].map(([label,value])=><div key={label}><dt>{label}</dt><dd>{budgetMoney(value)}</dd></div>)}</dl>
{allowed('approve')&&!budget.approved_by&&<button disabled={disabled} onClick={()=>send('approve',{budget_id:budget.id})}>Aprovar dotação por identidade distinta</button>}
</>}</section>
{(allowed('record')||allowed('consume'))&&!canMove&&<p role="status">Para operar, seleccione uma dotação aprovada do exercício actual com origem sintética no mesmo centro. O guião usa o centro ORC-DEMO.</p>}
{allowed('record')&&canMove&&<section className="panel"><h2>Movimentos da origem sintética</h2><p>Reserva → ajuste de adjudicação → compromisso. A libertação só está disponível antes do compromisso. Uma repetição não volta a movimentar o saldo.</p>
<form key={`movement-${budget.id}`} onSubmit={submit('movement',d=>({...d,origin_id:Number(d.origin_id)}))}><fieldset disabled={disabled}><label>Origem<select name="origin_id">{originOptions}</select></label><label>Movimento<select name="kind">{Object.entries(kinds).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label>{moneyInput('total','Total indicado (MZN)','300.00')}{moneyInput('subtotal','Subtotal sem imposto (MZN)','300.00')}<button>Registar movimento sintético</button></fieldset></form>
</section>}
{allowed('consume')&&canMove&&<section className="panel"><h2>Consumo orçamental simulado</h2><p>Requer compromisso anterior. A referência identifica uma simulação; não cria uma factura ou obrigação.</p><form key={`consume-${budget.id}`} onSubmit={submit('consume',d=>({...d,origin_id:Number(d.origin_id)}))}><fieldset disabled={disabled}><label>Origem<select name="origin_id">{originOptions}</select></label><label>Referência sintética<input name="reference" defaultValue="SIM-CONSUMO-001" required minLength={3} maxLength={100} /></label>{moneyInput('total','Total simulado (MZN)','100.00')}{moneyInput('subtotal','Subtotal simulado (MZN)','100.00')}<button>Registar consumo simulado</button></fieldset></form></section>}
{allowed('reinforcement.propose')&&budget?.approved_by&&<section className="panel"><h2>Propor reforço da dotação #{budget.id}</h2><form onSubmit={submit('reinforcement',d=>({...d,budget_id:budget.id}))}><fieldset disabled={disabled}><label>Referência<input name="reference" defaultValue="SIM-REFORCO-001" required maxLength={100} /></label>{moneyInput('amount','Montante do reforço (MZN)','200.00')}<label>Fundamento<textarea name="reason" required minLength={3} defaultValue="Reforço sintético para ensaio da equipa." /></label><button>Propor reforço</button></fieldset></form></section>}
<section className="panel"><h2>Reforços e decisões</h2>{data.reinforcements.length===0?<p>Sem propostas de reforço.</p>:data.reinforcements.map(r=><article key={r.id}><h3>{r.reference} · {budgetMoney(r.amount_minor)}</h3><p>Dotação #{r.budget_id} · {r.status} · Proponente: {data.actors[r.created_by]??r.created_by}</p><p>{r.reason}</p>{r.review_reason&&<p>Decisão: {r.review_reason}</p>}{allowed('reinforcement.review')&&r.status==='pending'&&<form onSubmit={submit('review',d=>({...d,reinforcement_id:r.id}))}><fieldset disabled={disabled}><label>Decisão<select name="decision"><option value="approved">Aprovar</option><option value="rejected">Recusar</option></select></label><label>Fundamento<textarea name="reason" required minLength={3} /></label><button>Registar decisão distinta</button></fieldset></form>}</article>)}</section>
<section className="panel"><h2>Movimentos rastreáveis</h2><div className="table-scroll"><table><thead><tr><th>Movimento</th><th>Dotação / origem</th><th>Actor</th><th>Reserva Δ</th><th>Compromisso Δ</th><th>Data</th></tr></thead><tbody>{data.entries.map(e=><tr key={e.id}><td>#{e.id} · {kinds[e.kind]??e.kind}</td><td>#{e.budget_id} / {data.origins.find(o=>o.id===e.requisition_id)?.reference??e.requisition_id}</td><td>{data.actors[e.created_by]??e.created_by}</td><td>{budgetMoney(e.reserved_minor)}</td><td>{budgetMoney(e.committed_minor)}</td><td>{e.created_at}</td></tr>)}</tbody></table></div>{!data.entries.length&&<p>Ainda não há movimentos.</p>}</section>
<section className="panel"><h2>Consumos e respectivas referências</h2>{data.consumption_sources.map(s=><p key={s.id}>{s.reference} · origem #{s.origin_id} · total {budgetMoney(s.total_minor)} · efeito #{data.consumptions.find(c=>c.invoice_id===s.id)?.id??'pendente'}</p>)}</section>
<section className="panel"><h2>Histórico de decisões e efeitos</h2>{data.history.map(h=><details key={h.id}><summary>#{h.id} · {h.action} · {data.actors[h.actor_id]??h.actor_id} · {h.created_at}</summary><pre>{JSON.stringify(typeof h.payload==='string'?JSON.parse(h.payload):h.payload,null,2)}</pre></details>)}</section>
</>}
</section>;
}
+5 -5
View File
@@ -86,13 +86,13 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
{area === 'service_drafts' && form.lines.length < 100 && <button type="button" onClick={() => setForm({ ...form, lines: [...form.lines, newLine()] })}>Adicionar linha</button>}
</>}
<div className="actions a-form-actions"><button className="primary" disabled={busy}>Guardar {isDraft(area) ? 'rascunho' : 'cadastro'}</button><button type="button" onClick={() => setForm(null)}>Cancelar edição</button></div>
</form> : selected ? <><div className="a-heading"><h2>{selected.name}</h2>{allowed('edit') && !selected.confirmation && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Origem em rascunho' : ''}</p>
{selected.data.description && <p>{selected.data.description}</p>}
</form> : selected ? <><div className="a-heading"><h2>{selected.name}</h2>{allowed('edit') && !selected.confirmation && !selected.workshop_commercial_state && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Origem em rascunho' : ''}</p>
{selected.workshop_commercial_state && <p className="alert home-notice">{selected.workshop_commercial_state === "superseded" ? "HISTÓRICO — preparação comercial substituída. Sem nova confirmação." : selected.workshop_commercial_state === "cancelled" ? "PREPARAÇÃO COMERCIAL CANCELADA — apenas consulta histórica." : "Origem de oficina protegida. Correcção apenas pelo circuito comercial próprio."}</p>}{selected.data.description && <p>{selected.data.description}</p>}
{isDraft(area) && <><p><strong>Cliente:</strong> {selected.data.client_name}</p>{selected.source_id && <p>Origem: prestação #{selected.source_id}, versão {selected.data.source_version}</p>}<p><strong>Moeda:</strong> {selected.data.currency || 'Não indicada — pendente'}</p><div className="table-scroll"><table><thead><tr><th>Serviço</th><th>Quantidade</th><th>Preço proposto</th></tr></thead><tbody>{selected.data.lines.map((line, i) => <tr key={i}><td>{line.service_name}</td><td>{line.quantity ?? 'Por preencher'}</td><td>{line.unit_price ?? 'Por preencher'}</td></tr>)}</tbody></table></div><p><strong>Base aritmética:</strong> {selected.base_amount ?? 'Incompleta'} {selected.data.currency || '(moeda pendente)'}</p><p className="help">Esta base não é um total fiscal ou valor definitivo a pagar. Imposto por validar; contabilidade não processada.</p>
{Object.keys(selected.pending).length > 0 && <div className="a-pending"><h3>Pendências</h3><ul>{Object.entries(selected.pending).map(([code, message]) => <li key={code}>{message}</li>)}</ul></div>}
{area === 'service_drafts' && can('billing_drafts.create', context.unit?.id) && <button className="primary" disabled={busy} onClick={prepareBilling}>Preparar rascunho de facturação</button>}
{area === 'billing_drafts' && allowed('edit') && !selected.confirmation && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
{area === 'billing_drafts' && !selected.confirmation && can('billing_drafts.confirm_internal', context.unit?.id) && <div className="a-pending"><h3>Confirmação interna</h3><p>Fixa esta versão e os dados que a sustentam. Bloqueia a edição deste rascunho. Não confirma a execução do serviço, não emite factura nem reconhece receita.</p>{reviewConfirmation ? <><p>Confirmar a versão {selected.version} neste contexto: {context.company.name} / {context.unit?.name}? Quantidades e preços devem estar completos; moeda e regras desconhecidas ficam pendentes na fotografia.</p><div className="actions"><button className="primary" disabled={busy} onClick={() => perform(async () => { await api(`/api/b1/billing-drafts/${selected.id}/confirm-internal`, 'POST', { version: selected.version }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Confirmado internamente — sem validade fiscal')}>Confirmar internamente esta versão</button><button onClick={() => setReviewConfirmation(false)}>Voltar ao rascunho</button></div></> : <button disabled={busy} onClick={() => { setKey(crypto.randomUUID()); setReviewConfirmation(true); }}>Rever confirmação interna</button>}</div>}
{area === 'service_drafts' && !selected.workshop_commercial_state && can('billing_drafts.create', context.unit?.id) && <button className="primary" disabled={busy} onClick={prepareBilling}>Preparar rascunho de facturação</button>}
{area === 'billing_drafts' && allowed('edit') && !selected.confirmation && !selected.workshop_commercial_state && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
{area === 'billing_drafts' && !selected.confirmation && (!selected.workshop_commercial_state || selected.workshop_commercial_state === 'current') && can('billing_drafts.confirm_internal', context.unit?.id) && <div className="a-pending"><h3>Confirmação interna</h3><p>Fixa esta versão e os dados que a sustentam. Bloqueia a edição deste rascunho. Não confirma a execução do serviço, não emite factura nem reconhece receita.</p>{reviewConfirmation ? <><p>Confirmar a versão {selected.version} neste contexto: {context.company.name} / {context.unit?.name}? Quantidades e preços devem estar completos; moeda e regras desconhecidas ficam pendentes na fotografia.</p><div className="actions"><button className="primary" disabled={busy} onClick={() => perform(async () => { await api(`/api/b1/billing-drafts/${selected.id}/confirm-internal`, 'POST', { version: selected.version }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Confirmado internamente — sem validade fiscal')}>Confirmar internamente esta versão</button><button onClick={() => setReviewConfirmation(false)}>Voltar ao rascunho</button></div></> : <button disabled={busy} onClick={() => { setKey(crypto.randomUUID()); setReviewConfirmation(true); }}>Rever confirmação interna</button>}</div>}
{selected.confirmation && <InternalDocument confirmation={selected.confirmation} />}
{area === 'billing_drafts' && (selected.confirmation ? <button onClick={() => setPreviewMode('history')}>Abrir confirmação interna histórica</button> : can('issuer_documents.view') && can('client_documents.view') && can('draft_documents.view',context.unit?.id) && <button className="primary" onClick={() => setPreviewMode('draft')}>Pré-visualizar rascunho</button>)}
</>}
+1 -1
View File
@@ -10,5 +10,5 @@ export default function WorkshopBillingWorkspace({context,can,api,busy}){
return <section><p className="eyebrow">WORKSHOP-BILLING-DRAFT-SIM-v1</p><h1>Oficina → rascunho de facturação</h1><div className="alert home-notice">ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Preços comerciais explícitos; custo interno não determina preço de venda. Sem emissão ou confirmação automática.</div><div ref={feedback} aria-live="polite">{notice&&<p className="alert success">{notice}</p>}{error&&<p role="alert" className="alert error">{error}</p>}</div>{!data?<p>A carregar…</p>:data.blocked?<p>Conversão real bloqueada nesta instância.</p>:<><button onClick={()=>load().catch(e=>setError(e.message))} disabled={disabled}>Actualizar versões explicitamente</button><label>Ordem<select value={order?.id??''} onChange={e=>setOrderId(e.target.value)}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>{order?<><p>Cliente #{order.client_id} · MZN explícito · Seleccionar origens integrais verificadas.</p>
{allowed('prepare')&&!order.generation&&<form className="panel" noValidate onSubmit={e=>{e.preventDefault();try{const input=workshopBillingProposal(order,data.services,new FormData(e.currentTarget));send("proposals",input);}catch(error){setError(error.message);}}}><h2>Seleccionar e preparar proposta comercial</h2>{order.entries.map(r=><fieldset key={r.id}><legend>Origem #{r.id} v{r.version} · {r.kind==='time'?`${r.minutes} MIN verificados`:`${r.quantity} ${r.data.unit} consumidos — ${r.data.resource}`}</legend><label><input type="checkbox" name={`select-${r.id}`} style={{display:'inline',width:'auto'}}/>Incluir origem integral</label><label>Serviço comercial<select name={`service-${r.id}`}><option value="">Seleccionar serviço e versão</option>{data.services.map(s=><option key={s.id} value={s.id}>{s.name} · #{s.id} v{s.version}</option>)}</select></label><label>Preço comercial MZN/{r.kind==='time'?'H':r.data.unit}<input name={`price-${r.id}`} inputMode="decimal"/></label><label>Fonte comercial e versão (preenchimento obrigatório)<input name={`source-${r.id}`} placeholder="Escreva a referência da tabela"/></label></fieldset>)}<label>Fundamento da proposta / exclusões<input name="evidence" required minLength={3}/></label><button disabled={disabled}>Submeter proposta comercial</button></form>}
{order.proposals.map(p=><article className="panel" key={p.id}><h2>Proposta #{p.id} · v{p.version}</h2><p>{p.current?'Versão actual':'HISTÓRICO / DESACTUALIZADA'} · {p.decision?`${p.decision.decision==='approved'?'Revista e aprovada':'Recusada'} — ${p.decision.reason}`:'Aguarda revisão distinta'}</p><p>{p.snapshot.evidence}</p><table><thead><tr><th>Origem exacta</th><th>Serviço / versão</th><th>Quantidade comercial</th><th>Preço MZN</th><th>Fonte</th></tr></thead><tbody>{p.snapshot.lines.map(l=><tr key={l.entry_id}><td>#{l.entry_id} v{l.entry_version}</td><td>#{l.service_id} v{l.service_version}</td><td>{l.quantity} {l.unit}</td><td>{l.unit_price}</td><td>{l.price_source}</td></tr>)}</tbody></table><p>Soma sem imposto: <strong>{p.snapshot.base_exact} MZN</strong> · Fiscalidade pendente.</p>{allowed('review')&&p.current&&!p.decision&&<form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);send('reviews',{id:p.id,version:p.version,decision:f.get('decision'),reason:f.get('reason')});}}><label>Revisão comercial<select name="decision"><option value="approved">Aprovar versão exacta</option><option value="refused">Recusar versão exacta</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar revisão comercial</button></form>}{allowed('generate')&&p.current&&p.decision?.decision==='approved'&&!order.generation&&<button disabled={disabled} onClick={()=>send('generations',{id:p.id,version:p.version})}>Gerar prestação e rascunho rastreáveis</button>}</article>)}
{order.generation&&<section className="panel"><h2>Rascunho gerado — sem validade fiscal</h2><p>Prestação #{order.generation.service_draft_id} · Rascunho de facturação #{order.generation.billing_draft_id} · Proposta #{order.generation.proposal_id}</p><p>Origens utilizadas uma única vez. Edição directa bloqueada; correcção exige circuito próprio, fora deste recorte.</p></section>}</>:<p>Sem ordens autorizadas neste contexto.</p>}</>}</section>;
{order.generation&&<section className="panel"><h2>Rascunho original gerado — sem validade fiscal</h2>{order.generation.commercial_state === "superseded" && <p className="alert home-notice">Original substituído; consultar a cadeia em Correcção e cancelamento.</p>}{order.generation.latest_commercial_change?.kind === "cancel" && <p className="alert home-notice">Preparação comercial cancelada. Origens não libertadas.</p>}{order.generation.latest_commercial_change?.new_billing_id && <p>Destino sucessor: prestação #{order.generation.latest_commercial_change.new_service_id} · rascunho #{order.generation.latest_commercial_change.new_billing_id}</p>}<p>Prestação #{order.generation.service_draft_id} · Rascunho de facturação #{order.generation.billing_draft_id} · Proposta #{order.generation.proposal_id}</p><p>Origens utilizadas uma única vez. Edição directa bloqueada; correcção exige circuito próprio, fora deste recorte.</p></section>}</>:<p>Sem ordens autorizadas neste contexto.</p>}</>}</section>;
}
@@ -0,0 +1,13 @@
import React,{useEffect,useRef,useState} from 'react';
export default function WorkshopCorrectionsWorkspace({context,can,api,busy}){
const [data,setData]=useState(null),[selected,setSelected]=useState(''),[kind,setKind]=useState('replace'),[error,setError]=useState(''),[notice,setNotice]=useState(''),[working,setWorking]=useState(false);const retry=useRef(null);
const allowed=r=>can(`workshop_corrections.${r}`,context.unit?.id??null),disabled=busy||working;
const load=async()=>setData(await api('/api/workshop-corrections'));useEffect(()=>{setData(null);setSelected('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
const g=data?.generations.find(g=>String(g.id)===selected)??data?.generations[0];
async function send(action,input){setWorking(true);setError('');setNotice('');const path=`/api/workshop-corrections/${g.id}/${action}`,signature=JSON.stringify([path,input]);if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};try{const r=await api(path,'POST',input,context.token,retry.current.key);retry.current=null;setNotice(`Registado #${r.id} · versão ${r.version}.`);await load();}catch(e){setError(e.message);}finally{setWorking(false);}}
function request(e){e.preventDefault();const f=new FormData(e.currentTarget),last=g.requests.at(-1),base=g.current;const lines=kind==='replace'?base.original_origins.map((o,i)=>{const s=data.services.find(s=>String(s.id)===f.get(`service-${i}`));return {entry_id:o.entry_id,service_id:s?.id,service_version:s?.version,price:f.get(`price-${i}`),price_source:f.get(`source-${i}`),description:f.get(`description-${i}`)};}):[];send('requests',{version:(last?.version??0)+1,previous_id:last?.id??null,kind,billing_id:base.billing_id,billing_version:base.billing_version,service_id:base.service_id,service_version:base.service_version,lines,reason:f.get('reason')});}
return <section><p className="eyebrow">WORKSHOP-BILLING-CORRECTION-SIM-v1</p><h1>Correcção e cancelamento de oficina</h1><div className="alert home-notice">ENSAIO — SEM UTILIZAÇÃO REAL. Originais preservados; cancelamento não liberta origens para nova cobrança.</div>{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="alert success" role="status">{notice}</p>}{!data?<p>A carregar…</p>:data.blocked?<p>Operações reais bloqueadas.</p>:<><button onClick={()=>load().catch(e=>setError(e.message))} disabled={disabled}>Actualizar versões explicitamente</button><label>Geração de oficina<select value={g?.id??''} onChange={e=>setSelected(e.target.value)}>{data.generations.map(g=><option key={g.id} value={g.id}>Ordem #{g.order_id} · geração #{g.id}</option>)}</select></label>{!g?<p>Sem gerações autorizadas.</p>:<>{g.issue&&<p className="alert home-notice">{g.issue}</p>}{g.current&&<p>Destino actual: prestação #{g.current.service_id} v{g.current.service_version} · rascunho #{g.current.billing_id} v{g.current.billing_version} · MZN</p>}
{allowed('request')&&g.current&&<form className="panel" key={`${g.id}-${g.current.billing_id}-${kind}`} onSubmit={request}><h2>Preparar pedido versionado</h2><label>Operação<select value={kind} onChange={e=>setKind(e.target.value)}><option value="replace">Substituir por novos rascunhos</option><option value="cancel">Cancelar preparação comercial</option><option value="withdraw">Desistir do último pedido não aplicado</option></select></label>{kind==='replace'&&g.current.original_origins.map((o,i)=><fieldset key={o.entry_id}><legend>Origem #{o.entry_id} v{o.entry_version} · {o.quantity} {o.unit} integrais</legend><label>Serviço comercial<select name={`service-${i}`} required defaultValue={g.current.current_lines[i].service_id}>{data.services.map(s=><option key={s.id} value={s.id}>{s.name} · #{s.id} v{s.version}</option>)}</select></label><label>Preço comercial MZN/{o.unit}<input name={`price-${i}`} required defaultValue={g.current.current_lines[i].unit_price}/></label><label>Fonte e versão comercial<input name={`source-${i}`} required minLength={3} defaultValue={o.price_source}/></label><label>Descrição comercial<input name={`description-${i}`} required minLength={3} defaultValue={g.current.current_lines[i].description}/></label></fieldset>)}<label>Motivo<input name="reason" required minLength={3}/></label><button disabled={disabled}>Submeter pedido</button></form>}
{g.requests.map(r=><article className="panel" key={r.id}><h2>Pedido #{r.id} · v{r.version} · {r.kind==='replace'?'Substituição':r.kind==='cancel'?'Cancelamento':'Desistência'}</h2><p>{r.applied?'Aplicado':r.current?'Referências actuais':'Histórico / desactualizado'} · {r.review?`${r.review.decision==='approved'?'Aprovado':'Recusado'} — ${r.review.reason}`:'Sem decisão'}</p><p>{r.snapshot.reason}</p><p>Destino referido: prestação #{r.snapshot.baseline.service_id} · rascunho #{r.snapshot.baseline.billing_id}</p>{r.kind==='replace'&&<><table><thead><tr><th>Origem</th><th>Quantidade</th><th>Preço MZN</th><th>Fonte</th></tr></thead><tbody>{r.snapshot.lines.map(l=><tr key={l.entry_id}><td>#{l.entry_id} v{l.entry_version}</td><td>{l.quantity} {l.unit}</td><td>{l.unit_price}</td><td>{l.price_source}</td></tr>)}</tbody></table><p>Anterior: {r.snapshot.baseline.current_base_exact} MZN sem imposto. Nova soma: <strong>{r.snapshot.base_exact} MZN</strong>. Não é total fiscal.</p></>}{allowed('review')&&r.current&&!r.review&&!r.applied&&r.kind!=='withdraw'&&<form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);send('reviews',{id:r.id,version:r.version,decision:f.get('decision'),reason:f.get('reason')});}}><label>Decisão<select name="decision"><option value="approved">Aprovar versão exacta</option><option value="refused">Recusar versão exacta</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar revisão distinta</button></form>}{allowed('apply')&&r.current&&!r.applied&&r.review?.decision==='approved'&&<button disabled={disabled} onClick={()=>send('applications',{id:r.id,version:r.version})}>Aplicar {r.kind==='replace'?'substituição':'cancelamento'} revisto</button>}</article>)}
<h2>Histórico de destinos</h2><p>Original preservado: prestação #{g.service_draft_id} · rascunho #{g.billing_draft_id}</p>{g.applications.map(a=><div className="panel" key={a.id}><p>{a.kind==='replace'?'Substituído':'Preparação cancelada'}: prestação #{a.old_service_id} · rascunho #{a.old_billing_id}</p>{a.new_billing_id&&<p>Sucessores: prestação #{a.new_service_id} · rascunho #{a.new_billing_id}</p>}<p>Origens canónicas preservadas; sem nova captura técnica.</p></div>)}</>}</>}</section>;
}
+4
View File
@@ -5,6 +5,8 @@ export default function WorkspaceHome({context,can,groups,navigate}) {
const available=groups.flatMap(g=>g.items);
const links=(rules)=>rules.filter(([,permission,scope])=>can(permission,scope)).map(([id,,,label])=>({id,label:label??available.find(i=>i.id===id)?.label})).filter(i=>available.some(a=>a.id===i.id));
const prepare=links([
['purchasing_budget','purchasing_budget.create',unit],['purchasing_budget','purchasing_budget.record',unit],
['workshop_corrections','workshop_corrections.request',unit],
['workshop_billing','workshop_billing.prepare',unit],
['clients','clients.create',null],['services','services.create',null],['service_drafts','service_drafts.create',unit],['billing_drafts','billing_drafts.create',unit],
['documents','issuer_documents.edit',null],['documents','client_documents.edit',null],['documents','draft_documents.edit',unit],
@@ -12,6 +14,8 @@ export default function WorkspaceHome({context,can,groups,navigate}) {
['workshop','workshop.open',unit],['direct_costs','direct_costs.prepare',unit],['industrial_costs','industrial_costs.prepare',unit],['industrial_budgets','industrial_budgets.prepare',unit],
]);
const validate=links([
['purchasing_budget','purchasing_budget.approve',unit],
['workshop_corrections','workshop_corrections.review',unit],
['workshop_billing','workshop_billing.review',unit],
['catalogue_accounting','catalogue_accounting.approve',null],['service_fiscal','service_fiscal.approve',null],['service_invoices','invoice_series.approve',null],
['service_receivables','service_receivables.confirm',unit],['service_receivables','service_receivables.correction_approve',unit],['workshop','workshop.verify',unit],['direct_costs','direct_costs.validate',unit],['industrial_costs','industrial_costs.validate',unit],['industrial_budgets','industrial_budgets.validate',unit],
+2 -1
View File
@@ -4,10 +4,11 @@ export function navigationGroups(can, unit) {
const item=(id,label,permission,scope=null)=>({id,label,visible:can(permission,scope)});
return [
{id:'start',label:'Início',items:[{id:'home',label:'Página inicial',visible:true}]},
{id:'purchasing',label:'Compras',items:[item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit)]},
{id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]},
{id:'billing',label:'Facturação',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit),item('service_invoices','Documentos e séries de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]},
{id:'receivables',label:'Recebimentos',items:[item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit)]},
{id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.view',unit),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit)]},
{id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.view',unit),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento','workshop_corrections.view',unit)]},
{id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]},
{id:'accounting',label:'Contabilidade e fiscalidade',items:[item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('access','Acessos','access.manage')]},
+5
View File
@@ -12,10 +12,12 @@ import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
import PurchasingBudgetWorkspace from './PurchasingBudgetWorkspace.jsx';
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.jsx';
import WorkspaceHome from './WorkspaceHome.jsx';
import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx';
import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.jsx';
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
@@ -70,6 +72,7 @@ function App() {
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
{page === 'home' && <WorkspaceHome context={context} can={can} groups={groups} navigate={navigate} />}
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'workshop_corrections' && <WorkshopCorrectionsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
@@ -78,6 +81,8 @@ function App() {
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'direct_costs' && <DirectCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'industrial_budgets' && <IndustrialBudgetsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'purchasing_budget' && <PurchasingBudgetWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'supplier_payables' && <section className="panel"><h1>Obrigações a fornecedores</h1><p>Percurso sintético ligado ao núcleo comum. Preparação, validação distinta e consulta, sem pagamentos ou vencimento presumido.</p><a href="/supplier-payables">Abrir preparação e consulta de obrigações</a></section>}
{page === 'industrial_costs' && <IndustrialCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
+9
View File
@@ -23,6 +23,15 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/logout', [IdentityController::class, 'logout']);
Route::post('/api/context', [IdentityController::class, 'select']);
Route::middleware(RequireCompanyContext::class)->group(function (): void {
Route::get('/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'page']);
Route::get('/api/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'index']);
Route::post('/api/supplier-payables/{action}', [\App\Modules\Purchasing\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate']);
Route::get('/api/purchasing-budget', [\App\Modules\Purchasing\BudgetController::class, 'index']);
Route::post('/api/purchasing-budget/{action}', [\App\Modules\Purchasing\BudgetController::class, 'command'])->whereIn('action', ['create', 'approve', 'movement', 'consume', 'reinforcement', 'review']);
Route::get('/api/workshop-corrections', [\App\Modules\Workshop\Http\WorkshopCorrectionsController::class,'index']);
Route::post('/api/workshop-corrections/{id}/requests', [\App\Modules\Workshop\Http\WorkshopCorrectionsController::class,'request'])->whereNumber('id');
Route::post('/api/workshop-corrections/{id}/reviews', [\App\Modules\Workshop\Http\WorkshopCorrectionsController::class,'review'])->whereNumber('id');
Route::post('/api/workshop-corrections/{id}/applications', [\App\Modules\Workshop\Http\WorkshopCorrectionsController::class,'apply'])->whereNumber('id');
Route::get('/api/workshop-billing', [\App\Modules\Workshop\Http\WorkshopBillingController::class,'index']);
Route::post('/api/workshop-billing/orders/{id}/proposals', [\App\Modules\Workshop\Http\WorkshopBillingController::class,'prepare'])->whereNumber('id');
Route::post('/api/workshop-billing/orders/{id}/reviews', [\App\Modules\Workshop\Http\WorkshopBillingController::class,'review'])->whereNumber('id');
+10 -4
View File
@@ -1,4 +1,4 @@
param([ValidateSet('start','stop')][string]$Action = 'start', [ValidateRange(1024,65535)][int]$Port = 8001, [switch]$ResumeIndustrialBudget)
param([ValidateSet('start','stop')][string]$Action = 'start', [ValidateRange(1024,65535)][int]$Port = 8001, [switch]$ResumeIndustrialBudget, [switch]$ProductDemo)
$ErrorActionPreference = 'Stop'
$sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
@@ -17,13 +17,19 @@ if ($Action -eq 'stop') {
Remove-Item -LiteralPath $recordFile
}
$fixtureFile = 'C:\laragon\data\sgf-tempo\secrets\browser-fixture.json'
if (Test-Path -LiteralPath $fixtureFile) { Remove-Item -LiteralPath $fixtureFile }
Write-Output 'Browser proof server stopped. Synthetic data remains only in sgf_tempo_test until the next test run.'
if (Test-Path -LiteralPath $fixtureFile) {
$fixture = Get-Content -LiteralPath $fixtureFile -Raw | ConvertFrom-Json
if (!$fixture.product_demo) { Remove-Item -LiteralPath $fixtureFile }
}
Write-Output 'Browser proof server stopped. Synthetic data remains in sgf_tempo_test; integrated demo manifests are preserved.'
exit 0
}
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) { throw "Port $Port occupied; no process was stopped." }
if (Test-Path -LiteralPath $recordFile) { throw 'Browser proof record exists; stop that proof first.' }
if ($ResumeIndustrialBudget) {
if ($ProductDemo -and $ResumeIndustrialBudget) { throw 'Choose one demonstration mode.' }
if ($ProductDemo) {
& $sgfPhp scripts/product-demo.php --env=testing
} elseif ($ResumeIndustrialBudget) {
& $sgfPhp scripts/industrial-budget-manual-prepare.php --env=testing
} else {
& $sgfPhp scripts/prepare-browser-fixture.php --env=testing
+50
View File
@@ -0,0 +1,50 @@
<?php
// Provision a new dedicated QA database/role, using the existing local maintenance tool connection.
// No reads/migrations of business databases; no schema changes to existing SGF demonstrations.
$sgf=realpath(__DIR__.'/..'); $compras=realpath($sgf.'/../compras');
require $compras.'/vendor/autoload.php';
foreach (['APP_ENV'=>'budget-coordinator','DB_CONNECTION'=>'pgsql','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array'] as $k=>$v) {putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$app=require $compras.'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
$proof=in_array('--proof',$argv,true)||in_array('--cleanup-proof',$argv,true);
$settingsFile='C:/laragon/data/sgf-tempo/secrets/'.($proof?'budget-proof.json':'budget-demo.json');
if(is_file($settingsFile)&&!in_array('--cleanup-proof',$argv,true)) {echo "Existing budget demonstration retained; no database, user or password replaced.\n";exit;}
$source=DB::connection('pgsql')->getConfig();
if(($source['host']??'')!=='127.0.0.1'||($source['driver']??'')!=='pgsql')throw new LogicException('Local maintenance connection required.');
config(['database.connections.budget_admin'=>[...$source,'database'=>'postgres','url'=>null]]);
$admin=DB::connection('budget_admin')->getPdo();
if(in_array('--cleanup-proof',$argv,true)) {
if(!is_file($settingsFile)){echo "No disposable budget proof to clean.\n";exit;}
$settings=json_decode(file_get_contents($settingsFile),true,32,JSON_THROW_ON_ERROR);
$database=$settings['DB_DATABASE'];$role=$settings['DB_USERNAME'];
if(!preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$database,$match)||$role!=='budget_demo_'.$match[1]||$settings['APP_ENV']!=='budget-testing')throw new LogicException('Unsafe proof cleanup target.');
$teamFile='C:/laragon/data/sgf-tempo/secrets/budget-demo.json';
if(is_file($teamFile)&&json_decode(file_get_contents($teamFile),true,32,JSON_THROW_ON_ERROR)['DB_DATABASE']===$database)throw new LogicException('Team demonstration must never be a proof cleanup target.');
$q=$admin->prepare('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=? AND pid<>pg_backend_pid()');$q->execute([$database]);
$admin->exec('DROP DATABASE "'.$database.'"');$admin->exec('DROP ROLE "'.$role.'"');
unlink($settingsFile);$access='C:/laragon/data/sgf-tempo/secrets/budget-proof-access.json';if(is_file($access))unlink($access);
echo "Disposable budget proof database/role removed; team demonstration preserved.\n";exit;
}
$suffix=bin2hex(random_bytes(6));$database='budget_core_sgf_'.$suffix;$role='budget_demo_'.$suffix;$password=bin2hex(random_bytes(32));
$roleCreated=false;$dbCreated=false;
try {
$admin->exec('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$admin->quote($password));$roleCreated=true;
$admin->exec('CREATE DATABASE "'.$database.'" OWNER "'.$role.'"');$dbCreated=true;
$settings=['APP_ENV'=>'budget-testing','APP_DEBUG'=>'false','APP_KEY'=>'base64:'.base64_encode(random_bytes(32)),'APP_URL'=>'http://127.0.0.1:8003','DB_CONNECTION'=>'pgsql','DB_HOST'=>'127.0.0.1','DB_PORT'=>(string)$source['port'],'DB_DATABASE'=>$database,'DB_USERNAME'=>$role,'DB_PASSWORD'=>$password,'DB_URL'=>'','SESSION_DRIVER'=>'file','SESSION_COOKIE'=>'sgf_budget_demo','SESSION_FILES'=>$sgf.'/storage/framework/budget-demo/sessions','CACHE_STORE'=>'file','CACHE_FILE_PATH'=>$sgf.'/storage/framework/budget-demo/cache','CACHE_LOCK_PATH'=>$sgf.'/storage/framework/budget-demo/locks','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array','BCRYPT_ROUNDS'=>'4'];
$settings['BUDGET_DEMO_ACCESS_FILE']='C:/laragon/data/sgf-tempo/secrets/'.($proof?'budget-proof-access.json':'budget-demo-access.json');
if($proof){
$settings['SESSION_COOKIE']='sgf_budget_proof';
foreach(['SESSION_FILES','CACHE_FILE_PATH','CACHE_LOCK_PATH'] as $k)$settings[$k]=str_replace('/budget-demo/','/budget-proof/',$settings[$k]);
}
foreach(['SESSION_FILES','CACHE_FILE_PATH','CACHE_LOCK_PATH'] as $k)if(!is_dir($settings[$k])&&!mkdir($settings[$k],0700,true))throw new RuntimeException('Cannot prepare isolated runtime directory.');
$process=new Process(['C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',$sgf.'/scripts/budget-demo-seed.php'],$sgf,$settings);$process->setTimeout(60);$process->run();
if(!$process->isSuccessful())throw new RuntimeException('Synthetic preparation failed: '.$process->getOutput().$process->getErrorOutput());
if(file_put_contents($settingsFile,json_encode($settings,JSON_THROW_ON_ERROR),LOCK_EX)===false)throw new RuntimeException('Cannot save isolated server settings.');
echo "Dedicated synthetic budget database and restricted QA role created. Four function accounts stored only in the protected local access file.\n";
} catch (\Throwable $e) {
if($dbCreated){$q=$admin->prepare('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=? AND pid<>pg_backend_pid()');$q->execute([$database]);$admin->exec('DROP DATABASE "'.$database.'"');}
if($roleCreated)$admin->exec('DROP ROLE "'.$role.'"');
throw $e;
}
+11
View File
@@ -0,0 +1,11 @@
<?php
// Local-only test host configuration; credentials never exposed through HTTP or source control.
$settingsFile=getenv('BUDGET_DEMO_SETTINGS_FILE') ?: 'C:/laragon/data/sgf-tempo/secrets/budget-demo.json';
if (!in_array($settingsFile,['C:/laragon/data/sgf-tempo/secrets/budget-demo.json','C:/laragon/data/sgf-tempo/secrets/budget-proof.json'],true)) {http_response_code(503);exit('Invalid test settings target.');}
$settings=json_decode(file_get_contents($settingsFile),true,32,JSON_THROW_ON_ERROR);
if (!preg_match('/^budget_core_sgf_[a-f0-9]{12}$/D',$settings['DB_DATABASE']??'') || ($settings['DB_HOST']??'')!=='127.0.0.1' || ($settings['APP_ENV']??'')!=='budget-testing') {
http_response_code(503); exit('Invalid isolated demonstration configuration.');
}
foreach ($settings as $key=>$value) { putenv($key.'='.$value); $_ENV[$key]=$_SERVER[$key]=$value; }
require __DIR__.'/router.php';
+52
View File
@@ -0,0 +1,52 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Purchasing\BudgetAdapter::guard();
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
if (Schema::hasTable('users')) throw new RuntimeException('Fresh isolated database required; nothing reset.');
require __DIR__.'/../packages/budget-control/tests/sgf-schema.php';
require __DIR__.'/../packages/budget-control/tests/host-support.php';
Schema::create('cb_ui_commands', function (Blueprint $t) {
$t->id(); foreach (['tenant_id','company_id','unit_id','actor_id'] as $c) $t->unsignedBigInteger($c);
$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) {
$t->id(); foreach (['tenant_id','company_id','unit_id','origin_id','created_by'] as $c) $t->unsignedBigInteger($c);
$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']);
$t->foreign('origin_id')->references('id')->on('cb_origins')->restrictOnDelete();
});
$f = DB::transaction(function () {
$f = budgetQaFixture('sgf');
DB::table('tenants')->where('id',$f['tenant'])->update(['name'=>'Cliente sintético — orçamento']);
DB::table('companies')->where('id',$f['company'])->update(['name'=>'Empresa Horizonte — Orçamento sintético']);
DB::table('units')->where('id',$f['unit'])->update(['name'=>'Unidade Central — Orçamento sintético']);
DB::table('cb_centres')->where('id',$f['centre'])->update(['code'=>'ORC-DEMO']);
DB::table('cb_origins')->where('id',$f['origin'])->update(['reference'=>'SIM-COMPRA-001']);
$f['release_origin'] = DB::table('cb_origins')->insertGetId($f['scope']+['cost_center_id'=>$f['centre'],'required_kind'=>'operating','reference'=>'SIM-COMPRA-LIBERTAR']);
$f['second_centre'] = DB::table('cb_centres')->insertGetId($f['scope']+['code'=>'ORC-OUTRO']);
DB::table('permission_grants')->where('user_id',$f['actor'])->whereIn('permission',['purchasing_budget.record','purchasing_budget.consume'])->delete();
$accounts=[];
foreach (['preparacao'=>['Preparação orçamental','preparacao-orcamento@example.test',$f['actor']], 'validacao'=>['Validação orçamental','validacao-orcamento@example.test',$f['validator']], 'operacao'=>['Operação orçamental','operacao-orcamento@example.test',null], 'consulta'=>['Consulta orçamental','consulta-orcamento@example.test',null]] as $role=>[$name,$email,$id]) {
$password=bin2hex(random_bytes(20));
$user=$id?\App\Models\User::findOrFail($id):new \App\Models\User;
$user->fill(['name'=>$name,'email'=>$email,'password'=>$password]); $user->save();
if (!$id) {
DB::table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company']]);
foreach (['company.view'=>null,'unit.view'=>$f['unit']] as $p=>$unit) DB::table('permission_grants')->insert(['user_id'=>$user->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$unit,'permission'=>$p,'created_at'=>now(),'updated_at'=>now()]);
foreach ($role==='operacao'?['view','record','consume']:['view'] as $p) DB::table('permission_grants')->insert($f['scope']+['user_id'=>$user->id,'permission'=>'purchasing_budget.'.$p,'created_at'=>now(),'updated_at'=>now()]);
}
$accounts[$role]=['id'=>$user->id,'email'=>$email,'password'=>$password];
}
// Another company is deliberately present without membership; the reserved unit has no grant.
return $f+['accounts'=>$accounts];
});
$file=getenv('BUDGET_DEMO_ACCESS_FILE') ?: 'C:/laragon/data/sgf-tempo/secrets/budget-demo-access.json';
if (!in_array($file,['C:/laragon/data/sgf-tempo/secrets/budget-demo-access.json','C:/laragon/data/sgf-tempo/secrets/budget-proof-access.json'],true)) throw new RuntimeException('Unexpected access manifest target.');
if (file_put_contents($file,json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR),LOCK_EX)===false)throw new RuntimeException('Cannot save protected synthetic access manifest.');
echo "Budget demonstration prepared with four distinct synthetic functions; no budget decisions or movements prefilled.\n";
+13
View File
@@ -0,0 +1,13 @@
<?php
$settings=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/budget-demo.json'),true,32,JSON_THROW_ON_ERROR);
foreach($settings as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Purchasing\BudgetAdapter::guard();
$db=\Illuminate\Support\Facades\DB::connection();
$role=$db->selectOne('select rolsuper,rolcreatedb,rolcreaterole from pg_roles where rolname=current_user');
if($role->rolsuper||$role->rolcreatedb||$role->rolcreaterole)throw new RuntimeException('Restricted test role required.');
$tables=[];foreach(['users','cb_budgets','cb_budget_entries','cb_budget_consumptions','cb_ui_commands'] as $t)$tables[$t]=$db->table($t)->count();
$report=['version'=>'SGF-BUDGET-WORKSPACE-SIM-v1','url'=>'http://127.0.0.1:8003','isolated_database'=>$db->getDatabaseName(),'restricted_role'=>true,'synthetic_only'=>true,'counts'=>$tables,'core_sha256'=>hash_file('sha256',__DIR__.'/../packages/budget-control/src/BudgetControl.php'),'checked_at'=>gmdate('c')];
file_put_contents(__DIR__.'/../docs/proofs/budget-demo-availability.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+28
View File
@@ -0,0 +1,28 @@
param([ValidateSet('start','stop','status')][string]$Action='status')
$ErrorActionPreference='Stop'
$budgetProject=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if($budgetProject -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path.'}
$budgetRecord='C:\laragon\data\sgf-tempo\run\budget-demo.json'
$budgetPhp='C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
if($Action -eq 'status') { Get-NetTCPConnection -LocalPort 8003 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress,LocalPort,OwningProcess; exit }
if($Action -eq 'stop') {
if(Test-Path -LiteralPath $budgetRecord){
$budgetProcessRecord=Get-Content -Raw -LiteralPath $budgetRecord | ConvertFrom-Json
$budgetProcess=Get-Process -Id $budgetProcessRecord.pid -ErrorAction SilentlyContinue
if($budgetProcess){if($budgetProcess.Path -ne $budgetPhp -or $budgetProcess.StartTime.ToUniversalTime().ToString('o') -ne $budgetProcessRecord.started){throw 'Process identity mismatch.'}; Stop-Process -Id $budgetProcess.Id}
Remove-Item -LiteralPath $budgetRecord
}
Write-Output 'Budget server stopped. Demonstration data, role and credentials retained.';exit
}
if(Get-NetTCPConnection -LocalPort 8003 -State Listen -ErrorAction SilentlyContinue){throw 'Port 8003 occupied; no process stopped.'}
if(Test-Path -LiteralPath $budgetRecord){throw 'Budget server record exists. Inspect/stop the owned server first.'}
$budgetSecrets='C:\laragon\data\sgf-tempo\secrets'
$budgetAccount=[Security.Principal.WindowsIdentity]::GetCurrent().Name
New-Item -ItemType Directory -Path $budgetSecrets -Force | Out-Null
& 'C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe' (Join-Path $PSScriptRoot 'budget-demo-provision.php')
if($LASTEXITCODE){throw 'Budget demonstration preparation failed.'}
foreach($budgetSecret in @('budget-demo.json','budget-demo-access.json')){& icacls.exe (Join-Path $budgetSecrets $budgetSecret) /inheritance:r /grant:r "${budgetAccount}:F" 'SYSTEM:F' | Out-Null}
New-Item -ItemType Directory -Path (Join-Path $budgetProject 'storage\framework\budget-demo\sessions'),(Join-Path $budgetProject 'storage\framework\budget-demo\cache'),(Join-Path $budgetProject 'storage\framework\budget-demo\locks') -Force | Out-Null
$budgetProcess=Start-Process -FilePath $budgetPhp -ArgumentList @('-S','127.0.0.1:8003','-t','public','scripts/budget-demo-router.php') -WorkingDirectory $budgetProject -WindowStyle Hidden -PassThru -RedirectStandardOutput 'C:\laragon\data\sgf-tempo\run\budget-demo.stdout.log' -RedirectStandardError 'C:\laragon\data\sgf-tempo\run\budget-demo.stderr.log'
@{pid=$budgetProcess.Id;started=$budgetProcess.StartTime.ToUniversalTime().ToString('o');port=8003}|ConvertTo-Json|Set-Content -LiteralPath $budgetRecord -Encoding UTF8
Write-Output 'Synthetic budget demonstration: http://127.0.0.1:8003. Existing SGF demonstrations preserved.'
+58
View File
@@ -0,0 +1,58 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
const fixture=JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/budget-proof-access.json','utf8'));
const base='http://127.0.0.1:8004', checks=[];
function client(){const cookies=new Map();let csrf='',context='';return {
async request(path,method='GET',body,options={}){
const r=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':options.noCsrf?'':csrf,'X-SGF-Context':options.stale?'stale-context':context,...(options.key?{'Idempotency-Key':options.key}:{})},...(body===undefined?{}:{body:JSON.stringify(body)})});
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const data=await r.json().catch(()=>({}));if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
return {status:r.status,data};
},
async login(role){assert.equal((await this.request('/api/session')).status,200);const a=fixture.accounts[role];assert.equal((await this.request('/api/login','POST',{email:a.email,password:a.password})).status,200);assert.equal((await this.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.unit})).status,200);},
async send(action,input,key=randomUUID(),expected=200){const r=await this.request('/api/purchasing-budget/'+action,'POST',input,{key});assert.equal(r.status,expected,action+': '+(r.data.message??''));return r.data;},
async list(){const r=await this.request('/api/purchasing-budget');assert.equal(r.status,200);return r.data;}
};}
const anonymous=client();assert.equal((await anonymous.request('/api/purchasing-budget')).status,401);
const p=client(),v=client(),o=client(),q=client();
await p.login('preparacao');await v.login('validacao');await o.login('operacao');await q.login('consulta');
const initial=await q.list();assert.equal(initial.budgets.length,0);checks.push('four_real_sessions_and_empty_initial_demo');
const allocation={cost_center_id:fixture.centre,year:initial.current_year,kind:'operating',includes_tax:true,amount:'1000.00',reason:'Dotação exclusivamente sintética para prova HTTP'};
const key=randomUUID(),created=await p.send('create',allocation,key);assert.equal((await p.send('create',allocation,key)).replayed,true);
await p.send('create',{...allocation,amount:'1001.00'},key,409);await q.send('create',allocation,randomUUID(),403);
await p.send('approve',{budget_id:created.id},randomUUID(),403);await v.send('approve',{budget_id:created.id});
const snapshots=[];
for(const [kind,amount,reserved,committed] of [['reserve','300.00',30000,0],['award','350.00',35000,0],['commit','350.00',0,35000]]){
const input={origin_id:fixture.origin,kind,total:amount,subtotal:amount},k=randomUUID();
await o.send('movement',input,k);assert.equal((await o.send('movement',input,k)).replayed,true);
const data=await q.list(),b=data.budgets.find(x=>x.id===created.id);assert.equal(b.balance.reserved_minor,reserved);assert.equal(b.balance.committed_minor,committed);snapshots.push({step:kind,balance:b.balance});
if(kind==='award')await o.send('movement',{origin_id:fixture.release_origin,kind:'reserve',total:'700.00',subtotal:'700.00'},randomUUID(),422);
}
await o.send('movement',{origin_id:fixture.origin,kind:'release',total:'0.00',subtotal:'0.00'},randomUUID(),409);
await o.send('movement',{origin_id:fixture.origin,kind:'reserve',total:'301.00',subtotal:'301.00'},randomUUID(),422);
await o.send('consume',{origin_id:fixture.origin,reference:'SIM-CONSUMO-HTTP',total:'100.001',subtotal:'100.00'},randomUUID(),422);
const consumption={origin_id:fixture.origin,reference:'SIM-CONSUMO-HTTP',total:'100.00',subtotal:'100.00'};
await o.send('consume',{...consumption,total:'400.00',subtotal:'400.00'},randomUUID(),422);
assert.equal((await q.list()).consumption_sources.length,0,'Failed consumption left a synthetic reference behind.');
await o.send('consume',consumption);await o.send('consume',consumption);
let data=await q.list(),b=data.budgets.find(x=>x.id===created.id);
assert.equal(data.entries.length,4);assert.equal(data.consumptions.length,1);assert.equal(data.consumption_sources.length,1);assert.equal(b.balance.committed_minor,25000);assert.equal(b.balance.consumed_minor,10000);assert.equal(b.balance.available_minor,65000);snapshots.push({step:'consume',balance:b.balance});
await o.send('consume',{...consumption,total:'101.00'},randomUUID(),409);
const proposed=await p.send('reinforcement',{budget_id:created.id,amount:'200.00',reference:'SIM-REFORCO-HTTP',reason:'Reforço sintético HTTP'});
assert.equal((await q.list()).budgets[0].balance.available_minor,65000);
await p.send('review',{reinforcement_id:proposed.id,decision:'approved',reason:'Tentativa sem direito'},randomUUID(),403);
await v.send('review',{reinforcement_id:proposed.id,decision:'approved',reason:'Revisão sintética distinta por HTTP'});
data=await q.list();assert.equal(data.budgets[0].balance.available_minor,85000);snapshots.push({step:'reinforcement',balance:data.budgets[0].balance});
for(const kind of ['reserve','release'])await o.send('movement',{origin_id:fixture.release_origin,kind,total:'200.00',subtotal:'200.00'});
assert.equal((await q.list()).budgets[0].balance.available_minor,85000);
await o.send('movement',{origin_id:fixture.release_origin,kind:'commit',total:'200.00',subtotal:'200.00'},randomUUID(),409);
await o.send('movement',{origin_id:999999,kind:'reserve',total:'1.00',subtotal:'1.00'},randomUUID(),403);
assert.equal((await o.request('/api/purchasing-budget/movement','POST',{origin_id:fixture.origin,kind:'reserve',total:'300.00',subtotal:'300.00'},{key:randomUUID(),stale:true})).status,409);
assert.equal((await o.request('/api/purchasing-budget/movement','POST',{}, {key:randomUUID(),noCsrf:true})).status,419);
assert.equal((await q.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.unit-1})).status,403);
checks.push('allocation_distinct_approval','same_command_recovery','changed_key_conflict','reservation_award_commit_equivalent','insufficient_balance','committed_release_refused','decimal_precision_rejected','failed_consumption_rollback_and_recovery','single_consumption_per_reference','pending_reinforcement_no_effect','distinct_reinforcement_review','release_once_no_reopening','foreign_origin_refused','stale_context_refused','csrf_enabled','reserved_unit_refused','reader_cannot_write');
for(const c of [p,v,o,q])assert.equal((await c.request('/api/logout','POST')).status,200);
const source=await fs.readFile('packages/budget-control/src/BudgetControl.php');
await fs.writeFile('docs/proofs/budget-workspace-http.json',JSON.stringify({version:'SGF-BUDGET-WORKSPACE-SIM-v1',checked_at:new Date().toISOString(),passed:true,core_sha256:createHash('sha256').update(source).digest('hex'),checks,snapshots,visual_review:false,team_demo_changed:false},null,2)+'\n');
console.log('Budget workspace HTTP proof passed: four functions, exact balances, traceable effects, no duplicate consumption, isolation and CSRF. Team demo unchanged.');
+25
View File
@@ -0,0 +1,25 @@
$ErrorActionPreference='Stop'
$budgetRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if($budgetRoot -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path.'}
if(Get-NetTCPConnection -LocalPort 8004 -State Listen -ErrorAction SilentlyContinue){throw 'Proof port occupied; no process stopped.'}
$budgetProvisionPhp='C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe'
$budgetWebPhp='C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
$budgetProofProcess=$null
try {
& $budgetProvisionPhp (Join-Path $PSScriptRoot 'budget-demo-provision.php') --proof
if($LASTEXITCODE){throw 'Isolated proof preparation failed.'}
$budgetAccount=[Security.Principal.WindowsIdentity]::GetCurrent().Name
foreach($budgetFile in @('budget-proof.json','budget-proof-access.json')){& icacls.exe "C:\laragon\data\sgf-tempo\secrets\$budgetFile" /inheritance:r /grant:r "${budgetAccount}:F" 'SYSTEM:F' | Out-Null}
$env:BUDGET_DEMO_SETTINGS_FILE='C:/laragon/data/sgf-tempo/secrets/budget-proof.json'
$budgetProofProcess=Start-Process -FilePath $budgetWebPhp -ArgumentList @('-S','127.0.0.1:8004','-t','public','scripts/budget-demo-router.php') -WorkingDirectory $budgetRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput 'C:\laragon\data\sgf-tempo\run\budget-proof.stdout.log' -RedirectStandardError 'C:\laragon\data\sgf-tempo\run\budget-proof.stderr.log'
Remove-Item Env:BUDGET_DEMO_SETTINGS_FILE
$budgetReady=$false
for($budgetAttempt=0;$budgetAttempt -lt 30;$budgetAttempt++) { if(Get-NetTCPConnection -LocalPort 8004 -State Listen -ErrorAction SilentlyContinue){$budgetReady=$true;break}; Start-Sleep -Milliseconds 100 }
if(!$budgetReady){throw 'Proof server did not start.'}
Push-Location $budgetRoot
try { & 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/budget-workspace-http-proof.mjs; if($LASTEXITCODE){throw 'HTTP budget workspace checks failed.'} } finally {Pop-Location}
} finally {
Remove-Item Env:BUDGET_DEMO_SETTINGS_FILE -ErrorAction SilentlyContinue
if($budgetProofProcess -and !$budgetProofProcess.HasExited){Stop-Process -Id $budgetProofProcess.Id}
& $budgetProvisionPhp (Join-Path $PSScriptRoot 'budget-demo-provision.php') --cleanup-proof
}
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import {createServer} from 'vite';
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
try {
const {budgetMoney}=await server.ssrLoadModule('/resources/js/PurchasingBudgetWorkspace.jsx');
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
assert.equal(budgetMoney(100000),'1 000.00 MZN');
assert.equal(budgetMoney(-35000),'-350.00 MZN');
assert.equal(budgetMoney(65000),'650.00 MZN');assert.equal(budgetMoney(85000),'850.00 MZN');
assert.equal(budgetMoney(99999999999999),'999 999 999 999.99 MZN');
assert.equal(budgetMoney(null),'Indisponível');
const visible=grants=>navigationGroups((p,u)=>grants.some(g=>g[0]===p&&g[1]===u),7).flatMap(g=>g.items).some(i=>i.id==='purchasing_budget');
assert(visible([['purchasing_budget.view',7]]));
assert(!visible([['purchasing_budget.view',8]]));assert(!visible([]));
console.log('Budget UI checks passed: exact cent formatting, signed deltas, unknown values and navigation scoped to the permitted unit. Not a browser visual review.');
} finally { await server.close(); }
+39
View File
@@ -0,0 +1,39 @@
param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly)
$ErrorActionPreference='Stop'
$payRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if($payRoot -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path'}
$payPrivate='C:/laragon/data/sgf-tempo/secrets/'
$payRun='C:\laragon\data\sgf-tempo\run'
$payPhp='C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe'
$payProof=$Action -eq 'proof';$payTag=if($payProof){'proof'}else{'demo'}
if($Action -eq 'status'){Get-NetTCPConnection -LocalPort 8005,8006 -State Listen -ErrorAction SilentlyContinue|Select-Object LocalAddress,LocalPort,OwningProcess;exit}
if($Action -eq 'stop'){
foreach($payMode in @('compras','sgf')){$payRecord=Join-Path $payRun "payables-$payMode.json";if(Test-Path -LiteralPath $payRecord){$paySaved=Get-Content -Raw -LiteralPath $payRecord|ConvertFrom-Json;$payProcess=Get-Process -Id $paySaved.pid -ErrorAction SilentlyContinue;if($payProcess){if($payProcess.Path -ne $paySaved.executable -or $payProcess.StartTime.ToUniversalTime().ToString('o') -ne $paySaved.started){throw 'Process mismatch'};Stop-Process -Id $payProcess.Id};Remove-Item -LiteralPath $payRecord}}
Write-Output 'Payables demo servers stopped; synthetic scenarios retained.';exit
}
$payPorts=if($payProof){@(8007,8008)}else{@(8005,8006)}
foreach($payPort in $payPorts){if(Get-NetTCPConnection -LocalPort $payPort -State Listen -ErrorAction SilentlyContinue){throw "Port $payPort occupied; no process stopped."}}
$payProcesses=@()
try{
if($payProof){& $payPhp (Join-Path $PSScriptRoot 'payables-provision.php') --proof}else{& $payPhp (Join-Path $PSScriptRoot 'payables-provision.php')}
if($LASTEXITCODE){throw 'Payables preparation failed'}
$payOwner=[Security.Principal.WindowsIdentity]::GetCurrent().Name
foreach($payFile in @(Get-ChildItem -LiteralPath $payPrivate -Filter "payables-$payTag-*.json" -File)){& icacls.exe $payFile.FullName /inheritance:r /grant:r "${payOwner}:F" 'SYSTEM:F'|Out-Null}
$payIndex=0
foreach($payMode in @('compras','sgf')){
$payHost=if($payMode -eq 'compras'){'C:\laragon\www\compras'}else{$payRoot}
$payWebPhp=if($payMode -eq 'compras'){$payPhp}else{'C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'}
$payPort=$payPorts[$payIndex];$payIndex++
$env:PAYABLE_SETTINGS_FILE=$payPrivate+"payables-$payTag-$payMode.json"
$payProcess=Start-Process -FilePath $payWebPhp -ArgumentList @('-S',"127.0.0.1:$payPort",'-t','public',"$payRoot\scripts\payables-router.php") -WorkingDirectory $payHost -WindowStyle Hidden -PassThru -RedirectStandardOutput "$payRun\payables-$payTag-$payMode.stdout.log" -RedirectStandardError "$payRun\payables-$payTag-$payMode.stderr.log"
$payProcesses+=$payProcess
Remove-Item Env:PAYABLE_SETTINGS_FILE
if(!$payProof){@{pid=$payProcess.Id;started=$payProcess.StartTime.ToUniversalTime().ToString('o');executable=$payWebPhp;port=$payPort}|ConvertTo-Json|Set-Content -LiteralPath "$payRun\payables-$payMode.json" -Encoding UTF8}
$payReady=$false;for($payAttempt=0;$payAttempt -lt 30;$payAttempt++){if(Get-NetTCPConnection -LocalPort $payPort -State Listen -ErrorAction SilentlyContinue){$payReady=$true;break};Start-Sleep -Milliseconds 100};if(!$payReady){throw 'Payables server failed to start'}
}
if($payProof){Push-Location $payRoot;try{if($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}}
else{Write-Output 'Synthetic payables: Compras http://127.0.0.1:8005/obrigacoes-fornecedores ; SGF http://127.0.0.1:8006. Budget demonstration preserved.'}
}finally{
Remove-Item Env:PAYABLE_SETTINGS_FILE -ErrorAction SilentlyContinue
if($payProof){foreach($payProcess in $payProcesses){if(!$payProcess.HasExited){Stop-Process -Id $payProcess.Id}};& $payPhp (Join-Path $PSScriptRoot 'payables-provision.php') --cleanup-proof}
}
+69
View File
@@ -0,0 +1,69 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
const run=promisify(execFile), reports=[];
const historyOnly=process.argv.includes('--history-only');
async function probe(mode,job,input={}){
// Use spawned stdin for payloads; credentials stay in protected settings read by PHP.
const {spawn}=await import('node:child_process');
return await new Promise((resolve,reject)=>{const p=spawn(mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/payables-probe.php',mode,job],{cwd:process.cwd(),windowsHide:true});let out='',err='';p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw new Error('QA probe failed: '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input));});
}
for(const mode of ['compras','sgf']){
const fixture=JSON.parse(await fs.readFile(`C:/laragon/data/sgf-tempo/secrets/payables-proof-${mode}-access.json`,'utf8'));
const base=mode==='compras'?'http://127.0.0.1:8007':'http://127.0.0.1:8008';
function client(){let csrf='',context='';const cookies=new Map();return {
async request(path,method='GET',body,opts={}){
const r=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':opts.noCsrf?'':csrf,'X-XSRF-TOKEN':opts.noCsrf?'':decodeURIComponent(cookies.get('XSRF-TOKEN')??''),'X-SGF-Context':opts.stale?'stale':context,...(opts.key?{'Idempotency-Key':opts.key}:{})},...(body===undefined?{}:{body:JSON.stringify(body)})});
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const text=await r.text();let data={};try{data=JSON.parse(text);}catch{}if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;return {status:r.status,data,text};
},
async login(role){await this.request(mode==='compras'?'/login':'/api/session');const a=fixture.accounts[role];const r=await this.request(mode==='compras'?'/login':'/api/login','POST',{email:a.email,password:a.password});assert.equal(r.status,200,mode+' login');if(mode==='sgf')assert.equal((await this.request('/api/context','POST',{tenant_id:fixture.scope.tenant_id,company_id:fixture.scope.company_id,unit_id:fixture.scope.unit_id})).status,200);},
async list(){const r=await this.request('/api/supplier-payables');assert.equal(r.status,200,mode+' listing '+r.text.slice(0,180));return r.data;},
async send(action,id,key=randomUUID(),expected=200){const r=await this.request('/api/supplier-payables/'+action,'POST',{id,reason:'Prova sintética de '+action},{key});assert.equal(r.status,expected,mode+' '+action+' '+r.text.slice(0,180));return r.data;}
};}
const anon=client();assert.equal((await anon.request('/api/supplier-payables')).status,401);
const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta');
let list=await q.list();assert.equal(list.obligations.length,0);const doc=list.documents[0];assert.equal(doc.subtotal_minor,1000000);assert.equal(doc.tax_minor,160000);assert.equal(doc.total_minor,1160000);assert.equal(doc.status,'validated');assert(doc.validation_audit_id);
const before=await probe(mode,'inspect');
if(historyOnly){
await probe(mode,'history');const existing=await probe(mode,'inspect');
const pending=await p.send('prepare',fixture.source_id);assert.equal(pending.state,'pending_source');assert(pending.missing.some(x=>x.includes('pagamentos')));
assert.equal((await v.send('validate',pending.proposal_id)).state,'conflict');
const refused=await probe(mode,'inspect');assert.equal(refused.counts.obligations,0);assert.deepEqual(refused.domain_hashes,existing.domain_hashes);if(mode==='compras')assert.equal(refused.legacy_balance,1130000);
reports.push({host:mode,passed:true,core_sha256:refused.core_sha256,source_revision:doc.revision,recognised_obligations:0,checks:['previous_settlement_blocks_recognition','history_unchanged','no_full_unpaid_import'],visual_review:false});continue;
}
const preparerPage=await p.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert.equal(preparerPage.status,200);assert(preparerPage.text.includes('data-action="prepare"'));
const readerPage=await q.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert(!readerPage.text.includes('<form data-action='));
const racePrepare=await probe(mode,'race',{role:'preparacao',action:'prepare',id:fixture.source_id});assert.equal(racePrepare.waits,2);assert.deepEqual(racePrepare.results.map(r=>r.state),['prepared','prepared']);
const key=randomUUID(),prepared=await p.send('prepare',fixture.source_id,key);assert.equal(prepared.state,'prepared');assert.equal((await p.send('prepare',fixture.source_id,key)).replayed,true);
const conflictKey=await p.request('/api/supplier-payables/prepare','POST',{id:fixture.source_id,reason:'Conteúdo divergente'},{key});assert.equal(conflictKey.status,409);
await q.send('prepare',fixture.source_id,randomUUID(),403);
await probe(mode,'grant',{role:'preparacao',action:'validate',enabled:true});await p.send('validate',prepared.proposal_id,randomUUID(),409);await probe(mode,'grant',{role:'preparacao',action:'validate',enabled:false});
await probe(mode,'source',{override:{status:'pending'}});const incomplete=await p.send('prepare',fixture.source_id);assert.equal(incomplete.state,'pending_source');assert(incomplete.missing.length>0);assert.equal((await q.list()).obligations.length,0);
assert.equal((await v.send('validate',prepared.proposal_id)).state,'conflict');
await probe(mode,'source');const ready=await p.send('prepare',fixture.source_id);assert.equal(ready.state,'prepared');assert.notEqual(ready.proposal_id,incomplete.proposal_id);
const revokedRace=await probe(mode,'race',{role:'validacao',action:'validate',id:ready.proposal_id,revoke:true});assert.deepEqual(revokedRace.results.map(r=>r.status),[403,403]);assert.equal((await q.list()).obligations.length,0);await probe(mode,'grant',{role:'validacao',action:'validate',enabled:true});
await probe(mode,'fault',{enabled:true});const failedKey=randomUUID();await v.send('validate',ready.proposal_id,failedKey,500);assert.equal((await probe(mode,'inspect')).counts.obligations,0);await probe(mode,'fault',{enabled:false});
const raceValidate=await probe(mode,'race',{role:'validacao',action:'validate',id:ready.proposal_id});assert.deepEqual(raceValidate.results.map(r=>r.state),['recognised','recognised']);
const recognised=await v.send('validate',ready.proposal_id,failedKey);assert.equal(recognised.state,'recognised');assert.equal((await v.send('validate',ready.proposal_id,failedKey)).replayed,true);
assert.equal((await p.send('prepare',fixture.source_id)).obligation_id,recognised.obligation_id);
list=await q.list();assert.equal(list.obligations.length,1);const o=list.obligations[0];assert.equal(o.principal_minor,1160000);assert.equal(o.balance_minor,1160000);assert.equal(o.due_on,null);assert.equal(o.days_overdue,null);assert.equal(o.due_status,'unknown');
const future=await probe(mode,'future');assert.equal(future[0].days_overdue,null);assert.equal(future[0].due_on,null);assert.equal(future[0].principal_minor,1160000);
const page=await q.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert(page.text.includes('11 600.00 MZN'));assert(page.text.includes('Principal reconhecido — vencimento pendente'));assert(page.text.includes('Dias de atraso: sem valor'));
const foreign=await probe(mode,'foreign');assert.equal(foreign.refused,true);
const foreignActor=await probe(mode,'foreign_actor');assert.equal(foreignActor.refused,true);
await probe(mode,'source',{override:{total_minor:1160001}});assert.equal((await p.send('prepare',fixture.source_id)).state,'conflict');assert.equal((await q.list()).obligations[0].principal_minor,1160000);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source');
const after=await probe(mode,'inspect');assert.deepEqual(after.domain_hashes,before.domain_hashes);assert.equal(after.counts.obligations,1);assert.equal(after.recognised_events,1);assert.equal(after.counts.events,after.counts.outbox);if(mode==='compras')assert.equal(after.legacy_balance,1160000);
await probe(mode,'history');const historyBefore=await probe(mode,'inspect');
const historyAttempt=await p.send('prepare',fixture.source_id);assert.equal(historyAttempt.state,'conflict');
const historyAfter=await probe(mode,'inspect');assert.equal(historyAfter.counts.obligations,1);assert.equal(historyAfter.obligations[0].balance_minor,null);assert.equal(historyAfter.obligations[0].principal_minor,1160000);
assert.deepEqual(historyBefore.domain_hashes,historyAfter.domain_hashes);if(mode==='compras')assert.equal(historyAfter.legacy_balance_blocked,409);
const denied=await v.request('/api/supplier-payables/validate','POST',{id:ready.proposal_id,reason:'Falta CSRF'},{key:randomUUID(),noCsrf:true});assert.equal(denied.status,419);
if(mode==='sgf'){assert.equal((await v.request('/api/supplier-payables/validate','POST',{id:ready.proposal_id,reason:'Contexto antigo'},{key:randomUUID(),stale:true})).status,409);assert.equal((await q.request('/api/context','POST',{tenant_id:fixture.scope.tenant_id,company_id:fixture.scope.company_id,unit_id:fixture.scope.unit_id-1})).status,403);}
reports.push({host:mode,passed:true,core_sha256:after.core_sha256,source_revision:doc.revision,principal_minor:o.principal_minor,due_on:null,days_overdue:null,races:[racePrepare,revokedRace,raceValidate],checks:['genuine_conferred_invoice','no_duplicate_new_keys','conflicting_payload','distinct_person','incomplete_source_pending','source_revision_conflict','revocation_while_waiting','event_fault_atomic_rollback','retry_after_failure','unknown_due_even_future','foreign_scope_refused','no_stock_budget_payment_effect','existing_settlement_history_refused_and_preserved','durable_events_outbox','read_only_ui','csrf_active'],visual_review:false});
}
assert.equal(reports[0].core_sha256,reports[1].core_sha256);assert.equal(reports[0].source_revision,reports[1].source_revision);
await fs.writeFile(historyOnly?'docs/proofs/payables-history.json':'docs/proofs/payables-http.json',JSON.stringify({version:'COMMON-PAYABLE-SOURCE-SIM-v1',passed:true,checked_at:new Date().toISOString(),team_budget_preserved:true,hosts:reports},null,2)+'\n');
console.log('Two payables hosts passed: one 11600 MZN obligation, unknown due date, concurrency, faults/recovery, versions, isolation and unchanged budget/stock/payments.');
+83
View File
@@ -0,0 +1,83 @@
<?php
// Fault/adversarial checks are permitted ONLY in the disposable proof databases.
$mode=$argv[1]??'';$job=$argv[2]??'';$private='C:/laragon/data/sgf-tempo/secrets/';
if(!in_array($mode,['compras','sgf'],true))throw new LogicException('Unknown proof host');
$settings=json_decode(file_get_contents($private.'payables-proof-'.$mode.'.json'),true,32,JSON_THROW_ON_ERROR);
$team=$private.'payables-demo-'.$mode.'.json';if(is_file($team)&&json_decode(file_get_contents($team),true)['DB_DATABASE']===$settings['DB_DATABASE'])throw new LogicException('Team demo protected');
foreach($settings as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
require $settings['HOST_ROOT'].'/vendor/autoload.php';$app=require $settings['HOST_ROOT'].'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
$db=DB::connection();if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$settings['DB_DATABASE'])throw new LogicException('Unsafe probe target');
$f=json_decode(file_get_contents($settings['PAYABLE_ACCESS_FILE']),true,32,JSON_THROW_ON_ERROR);
$input=json_decode(stream_get_contents(STDIN)?:'{}',true,64,JSON_THROW_ON_ERROR);
$class=$mode==='compras'?\App\Services\SupplierPayables::class:\App\Modules\Purchasing\SupplierPayables::class;
$select=function($role)use($mode,$f){$u=\App\Models\User::findOrFail($f['accounts'][$role]['id']);auth()->guard()->setUser($u);if($mode==='sgf')app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id,$f['scope']['tenant_id'],$f['scope']['company_id'],$f['scope']['unit_id']);return $u;};
if($job==='inspect'){
$counts=[];foreach(['sources','proposals','obligations','events','outbox','commands'] as $t)$counts[$t]=$db->table('pa_'.$t)->count();
$domain=[];if($mode==='compras')foreach(['invoices','invoice_items','budget_entries','budget_consumptions','stock_movements','external_payments'] as $t)$domain[$t]=hash('sha256',\Tempo\Finance\Payables\Payables::canonical($db->table($t)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()));
$u=$select('consulta');$d=app($class)->listing($u);$out=['counts'=>$counts,'domain_hashes'=>$domain,'core_sha256'=>hash_file('sha256',__DIR__.'/../packages/payables/src/Payables.php'),'recognised_events'=>$db->table('pa_events')->where('kind','payable.recognised')->count(),'obligations'=>$d['obligations']];
if($mode==='compras'){try{$out['legacy_balance']=app(\App\Services\ExternalPaymentWorkflow::class)->outstanding(\App\Models\Invoice::findOrFail($f['source_id']));}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$out['legacy_balance_blocked']=$e->getStatusCode();}}
}elseif($job==='grant'){
$role=$input['role'];$action=$input['action'];if(!isset($f['accounts'][$role])||!in_array($action,['view','prepare','validate'],true))throw new LogicException('Invalid synthetic grant');
$match=$f['scope']+['user_id'=>$f['accounts'][$role]['id'],'action'=>$action];
if($input['enabled'])$db->table('pa_access')->insertOrIgnore($match+['person'=>'synthetic-person-'.$role]);else$db->table('pa_access')->where($match)->delete();$out=['changed'=>true];
}elseif($job==='source'){
$original=json_decode(file_get_contents($settings['PAYABLE_BUNDLE']),true,64,JSON_THROW_ON_ERROR);
$over=$input['override']??[];if(array_diff(array_keys($over),['status','total_minor','supplier_name','currency','payment_count']))throw new LogicException('Invalid adversarial fields');
$s=array_replace($original,$over);
if($mode==='compras'){
$row=$db->table('invoices')->where('id',$f['source_id'])->first();$snap=json_decode($row->snapshot,true);$snap['currency']=$s['currency'];$snap['supplier']['name']=$s['supplier_name'];
$db->table('invoices')->where('id',$f['source_id'])->update(['status'=>$s['status'],'total_minor'=>$s['total_minor'],'snapshot'=>json_encode($snap,JSON_THROW_ON_ERROR)]);
}else{$db->table('pa_imported_sources')->where('id',$f['source_id'])->update(['payload'=>\Tempo\Finance\Payables\Payables::canonical($s),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($s))]);}
$out=['source_revision_test_only'=>true];
}elseif($job==='fault'){
if($input['enabled'])$db->unprepared("CREATE FUNCTION pa_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='payable.recognised' THEN RAISE EXCEPTION 'PA_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER pa_qa_fault BEFORE INSERT ON pa_events FOR EACH ROW EXECUTE FUNCTION pa_qa_fault()");
else $db->unprepared('DROP TRIGGER pa_qa_fault ON pa_events; DROP FUNCTION pa_qa_fault()');$out=['fault_configured'=>true];
}elseif($job==='worker'){
$u=$select($input['role']);$db->select('select set_config(?, ?, false)',['application_name',$input['label']]);
try{$r=app($class)->engine($u)->command($input['action'],$input['id'],'Concurrent synthetic proof',$input['key']);$out=['status'=>200,'state'=>$r['state']];}
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$out=['status'=>$e->getStatusCode()];}
$out['pid']=$db->selectOne('select pg_backend_pid() as p')->p;
}elseif($job==='race'){
$db->beginTransaction();$db->table($mode==='compras'?'tenants':'companies')->where('id',$mode==='compras'?$f['scope']['tenant_id']:$f['scope']['company_id'])->lockForUpdate()->firstOrFail();
$processes=[];$results=[];
try{
foreach([0,1] as $i){$p=new Process([PHP_BINARY,__FILE__,$mode,'worker'],$settings['HOST_ROOT']);$p->setInput(json_encode($input+['key'=>\Illuminate\Support\Str::uuid()->toString(),'label'=>'pa_proof_'.$i],JSON_THROW_ON_ERROR));$p->setTimeout(30);$p->start();$processes[]=$p;}
config(['database.connections.pa_monitor'=>$db->getConfig()]);$monitor=DB::connection('pa_monitor');$deadline=microtime(true)+15;$waits=0;
do{$waits=(int)$monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in ('pa_proof_0','pa_proof_1') and wait_event_type='Lock'",[$db->getDatabaseName()])->n;if($waits===2)break;foreach($processes as $p)if(!$p->isRunning())throw new RuntimeException('Worker exited: '.$p->getOutput().$p->getErrorOutput());usleep(20000);}while(microtime(true)<$deadline);
if($waits!==2)throw new RuntimeException('Concurrency overlap not observed');
if($input['revoke']??false)$db->table('pa_access')->where($f['scope'])->where(['user_id'=>$f['accounts']['validacao']['id'],'action'=>'validate'])->delete();
$db->commit();foreach($processes as $p){$p->wait();if(!$p->isSuccessful())throw new RuntimeException('Worker failed: '.$p->getOutput().$p->getErrorOutput());$results[]=json_decode($p->getOutput(),true,32,JSON_THROW_ON_ERROR);}
$out=['waits'=>$waits,'results'=>$results];
}finally{if($db->transactionLevel())$db->rollBack();foreach($processes as $p)if($p->isRunning())$p->stop();}
}elseif($job==='history'){
$u=$select('preparacao');
if($mode==='compras'){
$invoice=\App\Models\Invoice::findOrFail($f['source_id']);
$bank=\App\Models\SupplierBankDetail::query()->create(['tenant_id'=>$u->tenant_id,'supplier_id'=>$invoice->supplier_id,'created_by'=>$u->id,'revision'=>1,'bank_name'=>'Banco exclusivamente fictício','country'=>'MZ','currency'=>'MZN','beneficiary'=>'Titular de fixture histórica','account_identifier'=>'SYNTHETIC-HISTORY-NOT-A-BANK-ACCOUNT','evidence_reference'=>'Fixture histórica adversarial','reason'=>'Prova de exclusão de documentos com liquidações','created_at'=>now()]);
\App\Models\ExternalPayment::query()->create(['tenant_id'=>$u->tenant_id,'invoice_id'=>$invoice->id,'supplier_bank_detail_id'=>$bank->id,'created_by'=>$u->id,'reviewed_by'=>$f['accounts']['validacao']['id'],'status'=>'confirmed','amount_minor'=>30000,'currency'=>'MZN','paid_on'=>now()->format('Y-m-d'),'external_reference'=>'HISTORY-QA-ONLY','reference_hash'=>hash('sha256','HISTORY-QA-ONLY'),'payload_hash'=>hash('sha256','HISTORY-PAYLOAD'),'evidence_reference'=>'Historical test fixture, not executed payment','bank_snapshot'=>['synthetic_fixture'=>true],'review_reason'=>'Existing historical fact fixture','reviewed_at'=>now()]);
$rows=$db->table('external_payments')->orderBy('id')->get()->map(fn($r)=>(array)$r)->all();$out=['payment_history_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($rows))];
}else{
$r=$db->table('pa_imported_sources')->where('id',$f['source_id'])->first();$s=json_decode($r->payload,true);$s['payment_count']=1;
$db->table('pa_imported_sources')->where('id',$r->id)->update(['payload'=>\Tempo\Finance\Payables\Payables::canonical($s),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($s))]);$out=['historical_source_projection'=>true];
}
}elseif($job==='foreign_actor'){
$now=now();$tenant=$db->table('tenants')->insertGetId(['name'=>'Outra empresa sintética','created_at'=>$now,'updated_at'=>$now]);
if($mode==='compras'){
$u=\App\Models\User::factory()->create(['tenant_id'=>$tenant,'purchasing_role'=>'finance']);$scope=['tenant_id'=>$tenant,'company_id'=>0,'unit_id'=>0];
}else{
$company=$db->table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Outra empresa sintética','created_at'=>$now,'updated_at'=>$now]);$unit=$db->table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Outra unidade','created_at'=>$now,'updated_at'=>$now]);$scope=['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];
$u=\App\Models\User::create(['name'=>'Outro preparador sintético','email'=>'other-'.bin2hex(random_bytes(6)).'@example.test','password'=>bin2hex(random_bytes(20))]);
$db->table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company]);foreach(['company.view'=>null,'unit.view'=>$unit,'supplier_payables.view'=>$unit] as $permission=>$unitId)$db->table('permission_grants')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unitId,'permission'=>$permission,'created_at'=>$now,'updated_at'=>$now]);
}
foreach(['view','prepare'] as $action)$db->table('pa_access')->insert($scope+['user_id'=>$u->id,'person'=>'foreign-synthetic-person','action'=>$action]);
auth()->guard()->setUser($u);if($mode==='sgf')app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
try{app($class)->engine($u)->command('prepare',$f['source_id'],'Forbidden foreign document',\Illuminate\Support\Str::uuid()->toString());$out=['refused'=>false];}catch(\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException){$out=['refused'=>true];}
}elseif($job==='future'){
\Illuminate\Support\Carbon::setTestNow('2030-12-31 12:00:00');$out=app($class)->listing($select('consulta'))['obligations'];
}elseif($job==='foreign'){
$u=$select('consulta');$engine=app($class)->engine($u);$foreign=new \Tempo\Finance\Payables\Payables($db,$u->id,'foreign-person',['tenant_id'=>999999,'company_id'=>999999,'unit_id'=>999999],fn()=>null,fn($id)=>$mode==='compras'?app($class)->document($u,$f['source_id']):app($class)->document($u,$f['source_id']));
try{$foreign->command('prepare',$f['source_id'],'Wrong mapped context',\Illuminate\Support\Str::uuid()->toString());$out=['refused'=>false];}catch(\DomainException){$out=['refused'=>true];}
}else throw new LogicException('Unknown probe');
echo json_encode($out,JSON_THROW_ON_ERROR).PHP_EOL;
+30
View File
@@ -0,0 +1,30 @@
<?php
$sgf=realpath(__DIR__.'/..');$compras=realpath($sgf.'/../compras');require $compras.'/vendor/autoload.php';
foreach(['APP_ENV'=>'payables-coordinator','DB_CONNECTION'=>'pgsql','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array'] as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$app=require $compras.'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;use Symfony\Component\Process\Process;
$proof=in_array('--proof',$argv,true)||in_array('--cleanup-proof',$argv,true);$tag=$proof?'proof':'demo';$private='C:/laragon/data/sgf-tempo/secrets/';
$source=DB::connection('pgsql')->getConfig();if($source['host']!=='127.0.0.1')throw new LogicException('Local QA maintenance only.');
config(['database.connections.pa_admin'=>[...$source,'database'=>'postgres','url'=>null]]);$admin=DB::connection('pa_admin')->getPdo();
if(in_array('--cleanup-proof',$argv,true)){
foreach(['compras','sgf'] as $mode){$path=$private.'payables-proof-'.$mode.'.json';if(!is_file($path))continue;$s=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);$db=$s['DB_DATABASE'];$role=$s['DB_USERNAME'];
if(!preg_match('/^(compras_receipts_test_|payable_sgf_)([a-f0-9]{12})$/D',$db,$m)||$role!=='payable_qa_'.$m[2])throw new LogicException('Invalid proof cleanup target.');
$team=$private.'payables-demo-'.$mode.'.json';if(is_file($team)&&json_decode(file_get_contents($team),true)['DB_DATABASE']===$db)throw new LogicException('Team database protected.');
$q=$admin->prepare('select pg_terminate_backend(pid) from pg_stat_activity where datname=? and pid<>pg_backend_pid()');$q->execute([$db]);$admin->exec('DROP DATABASE "'.$db.'"');$admin->exec('DROP ROLE "'.$role.'"');unlink($path);$a=$private.'payables-proof-'.$mode.'-access.json';if(is_file($a))unlink($a);
}$bundle=$private.'payables-proof-source.json';if(is_file($bundle))unlink($bundle);echo "Disposable payables proof databases and roles removed.\n";exit;
}
$instance='qa-compras-'.bin2hex(random_bytes(16));$bundle=$private.'payables-'.$tag.'-source.json';
foreach(['compras','sgf'] as $mode){
$path=$private.'payables-'.$tag.'-'.$mode.'.json';if(is_file($path)){echo "Existing $mode payables scenario retained.\n";continue;}
if($mode==='sgf'&&is_file($private.'payables-'.$tag.'-compras.json'))$instance=json_decode(file_get_contents($private.'payables-'.$tag.'-compras.json'),true)['PAYABLE_SOURCE_INSTANCE'];
$suffix=bin2hex(random_bytes(6));$db=($mode==='compras'?'compras_receipts_test_':'payable_sgf_').$suffix;$role='payable_qa_'.$suffix;$pw=bin2hex(random_bytes(32));
$admin->exec('CREATE ROLE "'.$role.'" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '.$admin->quote($pw));
$admin->exec('CREATE DATABASE "'.$db.'" OWNER "'.$role.'"');
$root=$mode==='compras'?$compras:$sgf;$runtime=$root.'/storage/framework/payables/'.$tag;
$env=['APP_ENV'=>$mode==='compras'?'testing':'payable-testing','APP_DEBUG'=>'false','APP_KEY'=>'base64:'.base64_encode(random_bytes(32)),'DB_CONNECTION'=>'pgsql','DB_URL'=>'','DB_HOST'=>'127.0.0.1','DB_PORT'=>(string)$source['port'],'DB_DATABASE'=>$db,'DB_USERNAME'=>$role,'DB_PASSWORD'=>$pw,'SESSION_DRIVER'=>'file','SESSION_COOKIE'=>'payables_'.$tag.'_'.$mode,'SESSION_FILES'=>$runtime.'/sessions','CACHE_STORE'=>'file','CACHE_FILE_PATH'=>$runtime.'/cache','CACHE_LOCK_PATH'=>$runtime.'/locks','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array','BCRYPT_ROUNDS'=>'4','COMMON_PAYABLES_PATH'=>$sgf.'/packages/payables','PAYABLE_SOURCE_INSTANCE'=>$instance,'PAYABLE_ACCESS_FILE'=>$private.'payables-'.$tag.'-'.$mode.'-access.json','PAYABLE_BUNDLE'=>$bundle,'HOST_MODE'=>$mode,'HOST_ROOT'=>$root];
foreach(['sessions','cache','locks'] as $dir)if(!is_dir($runtime.'/'.$dir))mkdir($runtime.'/'.$dir,0700,true);
$php=$mode==='compras'?PHP_BINARY:'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
try{$p=new Process([$php,$sgf.'/packages/payables/tests/seed.php',$mode],$root,$env);$p->setTimeout(70);$p->run();if(!$p->isSuccessful())throw new RuntimeException('QA source preparation failed: '.$p->getOutput().$p->getErrorOutput());
$env['APP_ENV']='payable-testing';file_put_contents($path,json_encode($env,JSON_THROW_ON_ERROR),LOCK_EX);echo "Prepared isolated $mode payables scenario from conferred source.\n";
}catch(\Throwable $e){$q=$admin->prepare('select pg_terminate_backend(pid) from pg_stat_activity where datname=?');$q->execute([$db]);$admin->exec('DROP DATABASE "'.$db.'"');$admin->exec('DROP ROLE "'.$role.'"');throw $e;}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
$path=getenv('PAYABLE_SETTINGS_FILE')?:'';
if(!preg_match('~^C:/laragon/data/sgf-tempo/secrets/payables-(demo|proof)-(compras|sgf)\.json$~D',$path)){http_response_code(503);exit('Isolated host configuration required.');}
$s=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);
if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$s['DB_DATABASE'])||$s['DB_HOST']!=='127.0.0.1'||$s['APP_ENV']!=='payable-testing'){http_response_code(503);exit('Invalid test host.');}
foreach($s as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$public=realpath($s['HOST_ROOT'].'/public');$uri=rawurldecode(parse_url($_SERVER['REQUEST_URI'],PHP_URL_PATH)?:'/');$file=realpath($public.$uri);
if($uri!=='/'&&$file&&str_starts_with($file,$public.DIRECTORY_SEPARATOR)&&is_file($file))return false;
require $public.'/index.php';
+13
View File
@@ -0,0 +1,13 @@
<?php
if(!isset($argv[1])){
foreach(['compras','sgf'] as $mode){$php=$mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';$command=escapeshellarg($php).' '.escapeshellarg(__FILE__).' '.$mode;passthru($command,$status);if($status)exit($status);}exit;
}
$mode=$argv[1];if(!in_array($mode,['compras','sgf'],true))throw new LogicException('Unknown host');
$s=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$mode.'.json'),true,32,JSON_THROW_ON_ERROR);
foreach($s as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
require $s['HOST_ROOT'].'/vendor/autoload.php';$app=require $s['HOST_ROOT'].'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db=\Illuminate\Support\Facades\DB::connection();if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$s['DB_DATABASE'])throw new LogicException('Wrong QA database');
$role=$db->selectOne('select rolsuper,rolcreatedb,rolcreaterole from pg_roles where rolname=current_user');if($role->rolsuper||$role->rolcreatedb||$role->rolcreaterole)throw new LogicException('Restricted role required');
$counts=[];foreach(['sources','proposals','obligations','events','outbox'] as $t)$counts[$t]=$db->table('pa_'.$t)->count();
$report=['host'=>$mode,'isolated'=>true,'restricted_role'=>true,'counts'=>$counts,'core_sha256'=>hash_file('sha256',__DIR__.'/../packages/payables/src/Payables.php'),'checked_at'=>gmdate('c')];
file_put_contents(__DIR__.'/../docs/proofs/payables-demo-'.$mode.'.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL);echo json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+32
View File
@@ -0,0 +1,32 @@
// Directed HTTP checks; no business operations or credential output.
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
const fixture=JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json','utf8'));
assert.equal(fixture.product_demo?.version,'SGF-PRODUCT-DEMO-v1');
const base='http://127.0.0.1:8002';
const results=[];
for(const [role,email,paths,forbidden] of [
['preparador','gestor@example.test',['workshop','workshop-billing','workshop-corrections','direct-costs','industrial-costs','industrial-budgets','service-invoices','service-receivables'],null],
['validador','consulta@example.test',['workshop','workshop-billing','workshop-corrections','direct-costs','industrial-costs','industrial-budgets','service-invoices','service-receivables'],null],
['tecnico','tecnico-oficina@example.test',['workshop'],'industrial-costs'],
['verificador','verificador-oficina@example.test',['workshop'],'service-receivables'],
]) {
const cookies=new Map();let csrf='',context='';
async function request(path,method='GET',body){
const response=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':context},...(body===undefined?{}:{body:JSON.stringify(body)})});
for(const c of response.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const data=await response.json().catch(()=>({}));if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
return {status:response.status,data};
}
assert.equal((await request('/api/session')).status,200);
assert.equal((await request('/api/login','POST',{email,password:fixture.password})).status,200,role+' login');
assert.equal((await request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.unit})).status,200);
for(const path of paths)assert.equal((await request('/api/'+path)).status,200,role+' '+path);
if(forbidden)assert.equal((await request('/api/'+forbidden)).status,403,role+' forbidden module');
assert.equal((await request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:fixture.hidden})).status,403,role+' reserved unit');
assert.equal((await request('/api/logout','POST')).status,200);
results.push({role,modules:paths,unauthorised_unit_refused:true,...(forbidden?{unauthorised_module_refused:forbidden}:{})});
}
const report={demo:fixture.product_demo.version,checked_at:new Date().toISOString(),type:'HTTP integration, not visual acceptance',business_operations_changed:false,results};
await fs.writeFile('docs/proofs/product-demo-http.json',JSON.stringify(report,null,2)+'\n');
console.log('Integrated demo HTTP checks passed for four synthetic functions, module access and unit isolation. No business writes.');
+85
View File
@@ -0,0 +1,85 @@
<?php
// Consolidates existing synthetic fixtures; never migrates, resets or touches client data.
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Support\Facades\DB;
use Tests\Support\{DocumentPreviewFixture, WorkshopCorrectionFixture, IndustrialBudgetFixture, ReceivablesFixture};
TestingDatabaseGuard::assertSafe();
\App\Modules\Services\ServiceInvoicing::guard();
$file = 'C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
$verify = in_array('--verify', $argv, true);
$manifest = is_file($file) ? json_decode(file_get_contents($file), true, 64, JSON_THROW_ON_ERROR) : null;
if (isset($manifest['product_demo'])) {
foreach ($manifest['product_demo']['actors'] as $role => $id) User::findOrFail($id);
echo json_encode(['demo' => $manifest['product_demo'], 'changed' => false], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
exit;
}
// A missing manifest must never silently adopt unrelated test records.
if (!$manifest && DB::table('users')->exists()) throw new RuntimeException('Existing test data has no manifest. Nothing reset; recover its manifest first.');
if ($manifest && empty($manifest['password'])) throw new RuntimeException('Synthetic credential manifest required; no passwords changed.');
foreach (['ic_lots', 'si_invoices'] as $table) {
if (DB::table($table)->exists()) throw new RuntimeException('Existing isolated scenario requires reconciliation: '.$table.'. Nothing deleted.');
}
DB::beginTransaction();
try {
if ($manifest) {
$base = array_intersect_key($manifest, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
$base['manager'] = User::findOrFail($manifest['manager_id']);
$base['reader'] = User::findOrFail($manifest['reader_id']);
} else {
$base = DocumentPreviewFixture::create();
$manifest = array_intersect_key($base, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
$manifest['manager_id'] = $base['manager']->id;
$manifest['reader_id'] = $base['reader']->id;
}
if (isset($manifest['workshop_corrections'])) {
$workshop = $manifest['workshop_corrections'];
$tech = User::where('email', 'tecnico-oficina@example.test')->firstOrFail();
$reviewer = User::where('email', 'verificador-oficina@example.test')->firstOrFail();
if (!DB::table('wo_orders')->where(['id' => $workshop['order'], 'company_id' => $base['company'], 'unit_id' => $base['unit']])->exists()) throw new RuntimeException('Workshop manifest differs.');
} else {
if (DB::table('wo_orders')->exists()) throw new RuntimeException('Existing workshop has no reconciled manifest. Nothing overwritten.');
$w = WorkshopCorrectionFixture::create($base);
$workshop = array_intersect_key($w, array_flip(['order', 'time', 'material', 'service', 'client', 'generation', 'service_draft', 'billing_draft']));
$tech = $w['technician'];
$reviewer = $w['reviewer'];
$manifest['workshop_corrections'] = $workshop;
}
$industry = IndustrialBudgetFixture::create($base);
$receivables = ReceivablesFixture::create($base);
$actors = ['preparador' => $base['manager']->id, 'validador' => $base['reader']->id, 'tecnico' => $tech->id, 'verificador' => $reviewer->id];
if (count(array_unique($actors)) !== 4) throw new RuntimeException('Four distinct synthetic actors required.');
$permissions = app(Permissions::class);
foreach (['preparador' => ['workshop.verify', 'industrial_costs.validate'], 'validador' => ['workshop.record', 'industrial_costs.prepare'], 'tecnico' => ['workshop.verify'], 'verificador' => ['workshop.record']] as $role => $forbidden) {
foreach ($forbidden as $right) if ($permissions->has($actors[$role], $base['tenant'], $base['company'], $right, $base['unit'])) throw new RuntimeException('Role separation differs: '.$role);
}
$demo = ['version' => 'SGF-PRODUCT-DEMO-v1', 'synthetic_only' => true, 'actors' => $actors,
'workshop' => $workshop,
'industry' => array_intersect_key($industry, array_flip(['lot', 'refs', 'result'])),
'receivables' => array_intersect_key($receivables, array_flip(['source', 'draft', 'invoice', 'series', 'rule'])),
'prepared_at' => now()->toIso8601String()];
if ($verify) {
DB::rollBack();
} else {
// Manifest and data are committed together as far as possible; retain old file on write failure.
$encoded = json_encode($manifest + ['product_demo' => $demo], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (file_put_contents($file, $encoded, LOCK_EX) === false) throw new RuntimeException('Cannot save protected manifest.');
try { DB::commit(); } catch (\Throwable $e) {
file_put_contents($file, json_encode($manifest, JSON_THROW_ON_ERROR), LOCK_EX);
throw $e;
}
}
echo json_encode(['demo' => $demo, 'rolled_back' => $verify, 'client_database_changed' => false,
'credentials' => 'existing protected local manifest only', 'real_grants_created' => false], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
} catch (\Throwable $e) {
if (DB::transactionLevel()) DB::rollBack();
throw $e;
}
+5
View File
@@ -104,6 +104,11 @@ switch ($Action) {
'composer' { & $sgfPhp $sgfComposer @ToolArguments; if ($LASTEXITCODE) { throw 'Composer command failed.' } }
'npm' { & $sgfNode $sgfNpmCli @ToolArguments; if ($LASTEXITCODE) { throw 'npm command failed.' } }
'test' {
$demoManifest = Join-Path $sgfData 'secrets\browser-fixture.json'
if (Test-Path -LiteralPath $demoManifest) {
$demoFixture = Get-Content -LiteralPath $demoManifest -Raw | ConvertFrom-Json
if ($demoFixture.product_demo) { throw 'Integrated team demo preserved in sgf_tempo_test. Do not reset it for regression tests; use a separate test environment.' }
}
if (Test-Path -LiteralPath "$sgfRun\browser-proof.json") { throw 'Stop the browser proof before tests reset sgf_tempo_test.' }
Start-SgfDatabase
& $sgfPhp 'vendor/phpunit/phpunit/phpunit' @ToolArguments
@@ -0,0 +1,20 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Modules\Workshop\WorkshopCorrections;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
WorkshopCorrections::guard();if(DB::table('wo_orders')->exists())throw new RuntimeException('Empty synthetic workshop required; nothing deleted.');
$f=\Tests\Support\WorkshopCorrectionFixture::create();$base=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'generation'=>$f['generation']];$checks=[];$key=fn()=>(string)Str::uuid();
$pair=function($one,$two)use($base){$a=new Process([PHP_BINARY,'scripts/workshop-corrections-worker.php','--env=testing'],base_path(),null,json_encode($one+$base+['pause'=>true]));$b=new Process([PHP_BINARY,'scripts/workshop-corrections-worker.php','--env=testing'],base_path(),null,json_encode($two+$base));$a->start();if(!$a->waitUntil(fn($type,$v)=>str_contains($v,'LOCK_HELD')))throw new RuntimeException('Company lock not observed.');$b->start();$a->wait();$b->wait();$out=[];foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Worker failed.');$lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($s)=>str_starts_with($s,'{')));$out[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);}return $out;};
$check=function($out,bool $replay){if($out[0]['status']!==200||$out[1]['status']!==($replay?200:409)||($replay&&(!$out[1]['result']['replayed']||$out[0]['result']['id']!==$out[1]['result']['id'])))throw new RuntimeException('Unexpected concurrent outcome.');return $out[0]['result'];};
$lines=array_map(fn($id,$price)=>['entry_id'=>$id,'service_id'=>$f['service'],'service_version'=>1,'price'=>$price,'price_source'=>'WORKSHOP-SALES-SYNTHETIC-v2','description'=>'Descrição corrigida sintética.'],[$f['time'],$f['material']],['200','80']);
$p=['actor'=>$f['manager']->id,'method'=>'request','key'=>$key(),'input'=>['version'=>1,'previous_id'=>null,'kind'=>'replace','billing_id'=>$f['billing_draft'],'billing_version'=>1,'service_id'=>$f['service_draft'],'service_version'=>1,'lines'=>$lines,'reason'=>'Correcção sintética 500 para 510.']];$id=$check($pair($p,$p),true)['id'];$checks['request_replay_one_version']=true;
$p['key']=$key();$p['input']['version']=2;$p['input']['previous_id']=$id;$cancel=$p;$cancel['key']=$key();$cancel['input']['kind']='cancel';$cancel['input']['lines']=[];$id=$check($pair($p,$cancel),false)['id'];$checks['replacement_vs_cancellation_one_request_successor']=true;
$r=['actor'=>$f['reader']->id,'method'=>'review','key'=>$key(),'input'=>['id'=>$id,'version'=>2,'decision'=>'approved','reason'=>'Revisão distinta.']];$check($pair($r,$r),true);$checks['review_replay_one_decision']=true;
$a=['actor'=>$f['manager']->id,'method'=>'apply','key'=>$key(),'input'=>['id'=>$id,'version'=>2]];$applied=$check($pair($a,$a),true);$checks['apply_replay_one_successor_pair']=true;
$p['key']=$key();$p['input']['version']=3;$p['input']['previous_id']=$id;$p['input']['kind']='cancel';$p['input']['lines']=[];$p['input']['billing_id']=$applied['billing_draft_id'];$p['input']['service_id']=$applied['service_draft_id'];$id=$check($pair($p,$p),true)['id'];
$r['key']=$key();$r['input']['id']=$id;$r['input']['version']=3;$other=$r;$other['key']=$key();$other['input']['decision']='refused';$check($pair($r,$other),false);$checks['competing_reviews_one_decision']=true;
$a['key']=$key();$a['input']=['id'=>$id,'version'=>3];$b1=['actor'=>$f['manager']->id,'method'=>'b1','key'=>$key(),'input'=>['billing_id'=>$applied['billing_draft_id']]];$check($pair($a,$b1),false);$checks['cancellation_blocks_overlapping_b1']=true;
if(DB::table('wb_claims')->count()!==2||DB::table('wc_applications')->count()!==2||DB::table('b_internal_confirmations')->exists())throw new RuntimeException('History/claims or confirmation differs.');
file_put_contents(base_path('docs/proofs/workshop-corrections-concurrency.json'),json_encode(['policy'=>WorkshopCorrections::POLICY,'database'=>'sgf_tempo_test','processes_per_case'=>2,'checks'=>$checks,'source_claims_unchanged'=>2,'applications'=>2,'b1_created'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Six overlapping correction cases passed: one replacement, one cancellation, two original source claims, no B1.\n";
+8
View File
@@ -0,0 +1,8 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\WorkshopCorrections::guard();$path='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';$base=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);
if(\Illuminate\Support\Facades\DB::table('wo_orders')->exists())throw new RuntimeException('Fresh synthetic workshop required; nothing deleted.');
$f=\Tests\Support\WorkshopCorrectionFixture::create($base+['manager'=>\App\Models\User::findOrFail($base['manager_id']),'reader'=>\App\Models\User::findOrFail($base['reader_id'])]);
file_put_contents($path,json_encode($base+['workshop_corrections'=>array_intersect_key($f,array_flip(['order','time','material','service','client','generation','service_draft','billing_draft']))],JSON_THROW_ON_ERROR));
file_put_contents('C:/laragon/data/sgf-tempo/secrets/workshop-corrections-manual-entry.txt',"Synthetic SGF corrections proof only - http://127.0.0.1:8002\r\nPreparer/applicant: gestor@example.test\r\nReviewer: consulta@example.test\r\nTemporary synthetic password for these two accounts: ".$base['password']."\r\nNever paste credentials into chat, captures or reports.\r\n");
echo "Synthetic original 500 MZN prepared through existing workshop conversion with distinct review. No correction, cancellation or correction review prefilled.\n";
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows())throw new RuntimeException('Own application connection required.');
if(!DB::table('companies')->where(['id'=>1,'tenant_id'=>1,'name'=>'Tempo Lda'])->exists()||!DB::table('units')->where(['id'=>1,'tenant_id'=>1,'company_id'=>1,'name'=>'SEGI'])->exists())throw new RuntimeException('Scope differs.');
$grants=[];foreach([1=>['view','request','apply'],2=>['view','review']] as $actor=>$rights){if(!DB::table('company_memberships')->where(['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1])->exists())throw new RuntimeException('Membership missing.');foreach($rights as $right){$g=['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>'workshop_corrections.'.$right];$grants[]=$g+['already_granted'=>DB::table('permission_grants')->where($g)->exists()];}}
$prerequisites=[];foreach([1,2] as $actor){foreach(['workshop.view','workshop_billing.view','unit.view'] as $p)$prerequisites[]=['user_id'=>$actor,'permission'=>$p,'present'=>DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$p])->exists()];foreach(['company.view','clients.view','services.view'] as $p)$prerequisites[]=['user_id'=>$actor,'permission'=>$p,'present'=>DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>1,'company_id'=>1,'permission'=>$p])->whereNull('unit_id')->exists()];}foreach(['service_drafts.view','service_drafts.create','billing_drafts.view','billing_drafts.create'] as $p)$prerequisites[]=['user_id'=>1,'permission'=>$p,'present'=>DB::table('permission_grants')->where(['user_id'=>1,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$p])->exists()];
try{\App\Modules\Workshop\WorkshopCorrections::guard();throw new RuntimeException('Real corrections allowed.');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){if($e->getStatusCode()!==403)throw $e;}
echo json_encode(['mode'=>'preview_only','destination'=>'sgf_tempo_dev','migration_130000_applied'=>DB::table('migrations')->where('migration','2026_10_28_130000_create_workshop_billing_corrections')->exists(),'proposed_grants'=>$grants,'existing_prerequisites'=>$prerequisites,'real_corrections_blocked'=>true,'applied'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
+6
View File
@@ -0,0 +1,6 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\WorkshopCorrections::guard();$in=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);app(\App\Shared\Context\CompanyContext::class)->selectFor($in['actor'],$in['tenant'],$in['company'],$in['unit']);
if($in['pause']??false){$paused=false;\Illuminate\Support\Facades\DB::listen(function($q)use(&$paused){if(!$paused&&str_contains($q->sql,'"companies"')&&str_contains($q->sql,'for update')){$paused=true;echo "LOCK_HELD\n";flush();usleep(1200000);}});}
try{$out=$in['method']==='b1'?app(\App\Modules\Services\InternalConfirmation::class)->confirm($in['input']['billing_id'],$in['actor'],1,$in['key']):app(\App\Modules\Workshop\WorkshopCorrections::class)->{$in['method']}($in['generation'],$in['actor'],$in['input'],$in['key']);echo json_encode(['status'=>200,'result'=>$out],JSON_THROW_ON_ERROR).PHP_EOL;}
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){echo json_encode(['status'=>$e->getStatusCode()],JSON_THROW_ON_ERROR).PHP_EOL;}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature;
use App\Modules\Workshop\{WorkshopCorrections,WorkshopCommercialState};
use App\Modules\Services\{ServiceWorkflow,InternalConfirmation};
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\{WorkshopCorrectionFixture,IdentityFixture};
use Tests\TestCase;
final class WorkshopCorrectionsTest extends TestCase {
use RefreshDatabase;
private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=WorkshopCorrectionFixture::create();$this->login();}
private function login(string $who='manager'): void {$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');}
private function send(string $action,array $in,?string $key=null,?int $g=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/workshop-corrections/'.($g??$this->f['generation']).'/'.$action,$in);}
private function input(string $kind='replace',array $extra=[]): array {return array_replace(['version'=>1,'previous_id'=>null,'kind'=>$kind,'billing_id'=>$this->f['billing_draft'],'billing_version'=>1,'service_id'=>$this->f['service_draft'],'service_version'=>1,'reason'=>'Correcção comercial sintética, original preservado.','lines'=>$kind==='replace'?array_map(fn($id,$price)=>['entry_id'=>$id,'service_id'=>$this->f['service'],'service_version'=>1,'price'=>$price,'price_source'=>'WORKSHOP-SALES-SYNTHETIC-v2','description'=>'Descrição comercial corrigida.'],[$this->f['time'],$this->f['material']],['200','80']):[]],$extra);}
private function review(int $id,int $version=1): void {$this->login('reader');$this->send('reviews',['id'=>$id,'version'=>$version,'decision'=>'approved','reason'=>'Revisão comercial distinta da versão exacta.'])->assertOk();$this->login();}
public function test_immutable_replacement_510_and_cancel_preserve_claims_and_original(): void {
$old=DB::table('a_billing_drafts')->find($this->f['billing_draft'])->data;$n=DB::table('wo_entries')->count();$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->send('applications',['id'=>$p,'version'=>1])->assertStatus(409);$this->review($p);$key=(string)Str::uuid();$a=$this->send('applications',['id'=>$p,'version'=>1],$key)->assertOk()->assertJsonPath('base_exact','510');$this->send('applications',['id'=>$p,'version'=>1],$key)->assertOk()->assertJsonPath('id',$a->json('id'))->assertJsonPath('replayed',true);
$this->assertSame($old,DB::table('a_billing_drafts')->find($this->f['billing_draft'])->data);$this->assertSame('superseded',WorkshopCommercialState::state('billing_drafts',$this->f['billing_draft']));$this->assertSame('current',WorkshopCommercialState::state('billing_drafts',$a->json('billing_draft_id')));$this->assertSame(2,DB::table('wb_claims')->count());$this->assertSame($n,DB::table('wo_entries')->count());
$cancel=$this->send('requests',$this->input('cancel',['version'=>2,'previous_id'=>$p,'service_id'=>$a->json('service_draft_id'),'billing_id'=>$a->json('billing_draft_id')]))->assertOk()->json('id');$this->review($cancel,2);$this->send('applications',['id'=>$cancel,'version'=>2])->assertOk()->assertJsonPath('kind','cancel');$this->assertSame('cancelled',WorkshopCommercialState::state('billing_drafts',$a->json('billing_draft_id')));$this->send('requests',$this->input())->assertStatus(409);$this->assertSame(2,DB::table('wb_claims')->count());$this->assertSame(0,DB::table('b_internal_confirmations')->count());
}
public function test_self_review_roles_and_revocation_on_replay(): void {
$key=(string)Str::uuid();$p=$this->send('requests',$this->input(),$key)->assertOk()->json('id');IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'workshop_corrections.review',$this->f['unit']);$this->send('reviews',['id'=>$p,'version'=>1,'decision'=>'approved','reason'=>'Self review'])->assertForbidden();$this->login('reader');$this->send('requests',$this->input())->assertForbidden();$this->send('applications',['id'=>$p,'version'=>1])->assertForbidden();$this->login();DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'workshop_corrections.request'])->delete();$this->send('requests',$this->input(),$key)->assertForbidden();
}
public function test_successor_and_withdrawal_do_not_inherit_approval(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);$next=$this->send('requests',$this->input('replace',['version'=>2,'previous_id'=>$p]))->assertOk()->json('id');$this->send('applications',['id'=>$p,'version'=>1])->assertStatus(409);$this->send('applications',['id'=>$next,'version'=>2])->assertStatus(409);$withdraw=$this->send('requests',$this->input('withdraw',['version'=>3,'previous_id'=>$next]))->assertOk()->json('id');$this->login('reader');$this->send('reviews',['id'=>$withdraw,'version'=>3,'decision'=>'approved','reason'=>'Withdraw cannot be applied'])->assertStatus(409);$this->assertSame('current',WorkshopCommercialState::state('billing_drafts',$this->f['billing_draft']));
}
public function test_rollback_atomic_destinations_and_recovery(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);$count=DB::table('a_billing_drafts')->count();DB::unprepared("CREATE OR REPLACE FUNCTION sgf_wc_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'WC_OUTBOX_FAIL'; END; \$\$; CREATE TRIGGER wc_fail BEFORE INSERT ON wc_outbox FOR EACH ROW EXECUTE FUNCTION sgf_wc_fail()");$key=(string)Str::uuid();$this->send('applications',['id'=>$p,'version'=>1],$key)->assertStatus(500);$this->assertSame($count,DB::table('a_billing_drafts')->count());$this->assertSame('current',WorkshopCommercialState::state('billing_drafts',$this->f['billing_draft']));$this->assertSame(0,DB::table('wc_applications')->count());DB::unprepared('DROP TRIGGER wc_fail ON wc_outbox');$this->send('applications',['id'=>$p,'version'=>1],$key)->assertOk();
}
public function test_superseded_b1_and_successor_edit_or_second_billing_refused(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);$a=$this->send('applications',['id'=>$p,'version'=>1])->assertOk();app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);
try{app(InternalConfirmation::class)->confirm($this->f['billing_draft'],$this->f['manager']->id,1,(string)Str::uuid());$this->fail('Historical B1 allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());}
foreach([['billing_drafts',$a->json('billing_draft_id'),['version'=>1,'name'=>'Bypass']],['billing_drafts',null,['source_id'=>$a->json('service_draft_id')]]] as [$area,$id,$input]){try{app(ServiceWorkflow::class)->save($area,$id,$this->f['manager']->id,$input,(string)Str::uuid());$this->fail('Linked destination bypass');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());}}
}
public function test_confirmed_destination_and_incompatible_inputs_refused(): void {
$d=$this->input();$d['lines'][0]['quantity']='2';$this->send('requests',$d)->assertStatus(422);$d=$this->input();$d['client_id']=999;$this->send('requests',$d)->assertStatus(422);$this->send('requests',$this->input(),null,999999)->assertForbidden();
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(InternalConfirmation::class)->confirm($this->f['billing_draft'],$this->f['manager']->id,1,(string)Str::uuid());$this->send('requests',$this->input())->assertStatus(409);
}
public function test_late_b1_dependency_stops_previously_approved_application(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(InternalConfirmation::class)->confirm($this->f['billing_draft'],$this->f['manager']->id,1,(string)Str::uuid());$this->send('applications',['id'=>$p,'version'=>1])->assertStatus(409);$this->assertSame(0,DB::table('wc_applications')->count());
}
public function test_zero_is_explicit_missing_is_rejected_and_sql_history_blocked(): void {
$d=$this->input();$d['lines'][1]['price']='';$this->send('requests',$d)->assertStatus(422);$d['lines'][1]['price']='0';$p=$this->send('requests',$d)->assertOk()->json('id');$this->review($p);$a=$this->send('applications',['id'=>$p,'version'=>1])->assertOk()->assertJsonPath('base_exact','350');
foreach(['SELECT sgf_wc_assert_current('.$this->f['billing_draft'].')','UPDATE wc_requests SET kind=\'cancel\' WHERE id='.$p,'DELETE FROM a_billing_drafts WHERE id='.$a->json('billing_draft_id')] as $sql){try{DB::transaction(fn()=>DB::unprepared($sql));$this->fail('SQL protection bypass');}catch(\Illuminate\Database\QueryException $e){$this->assertNotEmpty($e->getMessage());}}
$this->app->detectEnvironment(fn()=>'local');try{WorkshopCorrections::guard();$this->fail('Real corrections allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(403,$e->getStatusCode());}
}
}
@@ -0,0 +1,15 @@
<?php
namespace Tests\Support;
use App\Modules\Workshop\WorkshopBilling;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Str;
final class WorkshopCorrectionFixture {
public static function create(?array $base=null): array {
$f=WorkshopBillingFixture::create($base);$w=app(WorkshopBilling::class);$key=fn()=>(string)Str::uuid();
$p=$w->prepare($f['order'],$f['manager']->id,WorkshopBillingFixture::proposal($f),$key());
app(CompanyContext::class)->selectFor($f['reader']->id,$f['tenant'],$f['company'],$f['unit']);$w->review($f['order'],$f['reader']->id,['id'=>$p['id'],'version'=>1,'decision'=>'approved','reason'=>'Proposta comercial sintética conferida.'],$key());
app(CompanyContext::class)->selectFor($f['manager']->id,$f['tenant'],$f['company'],$f['unit']);$g=$w->generate($f['order'],$f['manager']->id,['id'=>$p['id'],'version'=>1],$key());
foreach(['manager'=>['view','request','apply'],'reader'=>['view','review']] as $who=>$rights)foreach($rights as $r)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],'workshop_corrections.'.$r,$f['unit']);
return $f+['generation'=>$g['id'],'service_draft'=>$g['service_draft_id'],'billing_draft'=>$g['billing_draft_id']];
}
}