diff --git a/.gitignore b/.gitignore index df6d058..5512103 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db +/storage/framework/budget-demo/ +/storage/framework/budget-proof/ +/storage/framework/payables/ diff --git a/AGENTS.md b/AGENTS.md index 3e9dd9f..ffdd819 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index bce79fc..0a08b4b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/Modules/Purchasing/BudgetController.php b/app/Modules/Purchasing/BudgetController.php new file mode 100644 index 0000000..14a4cc0 --- /dev/null +++ b/app/Modules/Purchasing/BudgetController.php @@ -0,0 +1,19 @@ +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'); + } +} diff --git a/app/Modules/Purchasing/BudgetWorkspace.php b/app/Modules/Purchasing/BudgetWorkspace.php new file mode 100644 index 0000000..6aee90b --- /dev/null +++ b/app/Modules/Purchasing/BudgetWorkspace.php @@ -0,0 +1,147 @@ +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()]); + } + } +} diff --git a/app/Modules/Purchasing/SupplierPayables.php b/app/Modules/Purchasing/SupplierPayables.php new file mode 100644 index 0000000..0a4a379 --- /dev/null +++ b/app/Modules/Purchasing/SupplierPayables.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/app/Modules/Purchasing/SupplierPayablesController.php b/app/Modules/Purchasing/SupplierPayablesController.php new file mode 100644 index 0000000..8fe3b0b --- /dev/null +++ b/app/Modules/Purchasing/SupplierPayablesController.php @@ -0,0 +1,39 @@ +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()); + } + } +} diff --git a/app/Modules/Services/DocumentPreparation.php b/app/Modules/Services/DocumentPreparation.php index ca27478..e5f0356 100644 --- a/app/Modules/Services/DocumentPreparation.php +++ b/app/Modules/Services/DocumentPreparation.php @@ -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); diff --git a/app/Modules/Services/InternalConfirmation.php b/app/Modules/Services/InternalConfirmation.php index 54b2af5..f31cf0b 100644 --- a/app/Modules/Services/InternalConfirmation.php +++ b/app/Modules/Services/InternalConfirmation.php @@ -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.'); diff --git a/app/Modules/Services/ServiceWorkflow.php b/app/Modules/Services/ServiceWorkflow.php index 0f406e3..b5e0982 100644 --- a/app/Modules/Services/ServiceWorkflow.php +++ b/app/Modules/Services/ServiceWorkflow.php @@ -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.'); diff --git a/app/Modules/Workshop/Http/WorkshopCorrectionsController.php b/app/Modules/Workshop/Http/WorkshopCorrectionsController.php new file mode 100644 index 0000000..f974936 --- /dev/null +++ b/app/Modules/Workshop/Http/WorkshopCorrectionsController.php @@ -0,0 +1,12 @@ +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')));} +} diff --git a/app/Modules/Workshop/WorkshopBilling.php b/app/Modules/Workshop/WorkshopBilling.php index d99f182..4a01b89 100644 --- a/app/Modules/Workshop/WorkshopBilling.php +++ b/app/Modules/Workshop/WorkshopBilling.php @@ -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()];}); } } diff --git a/app/Modules/Workshop/WorkshopCommercialState.php b/app/Modules/Workshop/WorkshopCommercialState.php new file mode 100644 index 0000000..cf77cdd --- /dev/null +++ b/app/Modules/Workshop/WorkshopCommercialState.php @@ -0,0 +1,19 @@ +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.'); + } +} diff --git a/app/Modules/Workshop/WorkshopCorrections.php b/app/Modules/Workshop/WorkshopCorrections.php new file mode 100644 index 0000000..9cebbde --- /dev/null +++ b/app/Modules/Workshop/WorkshopCorrections.php @@ -0,0 +1,81 @@ +$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()];}); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 360143f..2c3b539 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()); } diff --git a/database/migrations/2026_10_28_130000_create_workshop_billing_corrections.php b/database/migrations/2026_10_28_130000_create_workshop_billing_corrections.php new file mode 100644 index 0000000..7c1c840 --- /dev/null +++ b/database/migrations/2026_10_28_130000_create_workshop_billing_corrections.php @@ -0,0 +1,16 @@ +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.');} +}; diff --git a/database/sql/workshop-corrections.sql b/database/sql/workshop-corrections.sql new file mode 100644 index 0000000..76c3cfd --- /dev/null +++ b/database/sql/workshop-corrections.sql @@ -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(); diff --git a/docs/PRODUCT_DEMO.md b/docs/PRODUCT_DEMO.md new file mode 100644 index 0000000..70c571b --- /dev/null +++ b/docs/PRODUCT_DEMO.md @@ -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. diff --git a/docs/PURCHASING_COMMON_CORE_PLAN.md b/docs/PURCHASING_COMMON_CORE_PLAN.md index bfa0bd3..1739d9b 100644 --- a/docs/PURCHASING_COMMON_CORE_PLAN.md +++ b/docs/PURCHASING_COMMON_CORE_PLAN.md @@ -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. diff --git a/docs/SERVICE_INVOICE_CALCULATION_SPEC.md b/docs/SERVICE_INVOICE_CALCULATION_SPEC.md index 99fbd19..d8737b3 100644 --- a/docs/SERVICE_INVOICE_CALCULATION_SPEC.md +++ b/docs/SERVICE_INVOICE_CALCULATION_SPEC.md @@ -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. diff --git a/docs/SGF_BUDGET_WORKSPACE_DELIVERY.md b/docs/SGF_BUDGET_WORKSPACE_DELIVERY.md new file mode 100644 index 0000000..230360d --- /dev/null +++ b/docs/SGF_BUDGET_WORKSPACE_DELIVERY.md @@ -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. diff --git a/docs/SUPPLIER_PAYABLE_DELIVERY.md b/docs/SUPPLIER_PAYABLE_DELIVERY.md new file mode 100644 index 0000000..435d22f --- /dev/null +++ b/docs/SUPPLIER_PAYABLE_DELIVERY.md @@ -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. diff --git a/docs/SUPPLIER_PAYABLE_SCOPE.md b/docs/SUPPLIER_PAYABLE_SCOPE.md new file mode 100644 index 0000000..6935b12 --- /dev/null +++ b/docs/SUPPLIER_PAYABLE_SCOPE.md @@ -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. diff --git a/docs/WORKSHOP_BILLING_CORRECTION_DELIVERY.md b/docs/WORKSHOP_BILLING_CORRECTION_DELIVERY.md new file mode 100644 index 0000000..6ad2a54 --- /dev/null +++ b/docs/WORKSHOP_BILLING_CORRECTION_DELIVERY.md @@ -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. diff --git a/docs/WORKSHOP_BILLING_CORRECTION_SCOPE.md b/docs/WORKSHOP_BILLING_CORRECTION_SCOPE.md new file mode 100644 index 0000000..914dda4 --- /dev/null +++ b/docs/WORKSHOP_BILLING_CORRECTION_SCOPE.md @@ -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. diff --git a/docs/WORKSHOP_BILLING_DRAFT_SCOPE.md b/docs/WORKSHOP_BILLING_DRAFT_SCOPE.md index 6cf8c76..a72cf48 100644 --- a/docs/WORKSHOP_BILLING_DRAFT_SCOPE.md +++ b/docs/WORKSHOP_BILLING_DRAFT_SCOPE.md @@ -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. diff --git a/docs/proofs/budget-demo-availability.json b/docs/proofs/budget-demo-availability.json new file mode 100644 index 0000000..32de188 --- /dev/null +++ b/docs/proofs/budget-demo-availability.json @@ -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" +} diff --git a/docs/proofs/budget-workspace-http.json b/docs/proofs/budget-workspace-http.json new file mode 100644 index 0000000..504279c --- /dev/null +++ b/docs/proofs/budget-workspace-http.json @@ -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 +} diff --git a/docs/proofs/payables-demo-compras.json b/docs/proofs/payables-demo-compras.json new file mode 100644 index 0000000..0280e0e --- /dev/null +++ b/docs/proofs/payables-demo-compras.json @@ -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" +} diff --git a/docs/proofs/payables-demo-sgf.json b/docs/proofs/payables-demo-sgf.json new file mode 100644 index 0000000..cd12da2 --- /dev/null +++ b/docs/proofs/payables-demo-sgf.json @@ -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" +} diff --git a/docs/proofs/payables-history.json b/docs/proofs/payables-history.json new file mode 100644 index 0000000..a86eec1 --- /dev/null +++ b/docs/proofs/payables-history.json @@ -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 + } + ] +} diff --git a/docs/proofs/payables-http.json b/docs/proofs/payables-http.json new file mode 100644 index 0000000..1f7bf7c --- /dev/null +++ b/docs/proofs/payables-http.json @@ -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 + } + ] +} diff --git a/docs/proofs/product-demo-http.json b/docs/proofs/product-demo-http.json new file mode 100644 index 0000000..fde3f22 --- /dev/null +++ b/docs/proofs/product-demo-http.json @@ -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" + } + ] +} diff --git a/docs/proofs/workshop-corrections-concurrency.json b/docs/proofs/workshop-corrections-concurrency.json new file mode 100644 index 0000000..38b9aeb --- /dev/null +++ b/docs/proofs/workshop-corrections-concurrency.json @@ -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 +} \ No newline at end of file diff --git a/docs/proofs/workshop-corrections-environment.json b/docs/proofs/workshop-corrections-environment.json new file mode 100644 index 0000000..bd8cf7a --- /dev/null +++ b/docs/proofs/workshop-corrections-environment.json @@ -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 +} diff --git a/docs/proofs/workshop-corrections-tests.json b/docs/proofs/workshop-corrections-tests.json new file mode 100644 index 0000000..b981814 --- /dev/null +++ b/docs/proofs/workshop-corrections-tests.json @@ -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 +} diff --git a/packages/payables/bootstrap.php b/packages/payables/bootstrap.php new file mode 100644 index 0000000..1980898 --- /dev/null +++ b/packages/payables/bootstrap.php @@ -0,0 +1,3 @@ +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()"); diff --git a/packages/payables/src/Payables.php b/packages/payables/src/Payables.php new file mode 100644 index 0000000..461af3d --- /dev/null +++ b/packages/payables/src/Payables.php @@ -0,0 +1,221 @@ +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]; + }); + } +} diff --git a/packages/payables/src/Workspace.php b/packages/payables/src/Workspace.php new file mode 100644 index 0000000..01d1141 --- /dev/null +++ b/packages/payables/src/Workspace.php @@ -0,0 +1,14 @@ +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]]]; +} diff --git a/packages/payables/tests/seed.php b/packages/payables/tests/seed.php new file mode 100644 index 0000000..309eee6 --- /dev/null +++ b/packages/payables/tests/seed.php @@ -0,0 +1,56 @@ +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"; diff --git a/packages/payables/ui/page.php b/packages/payables/ui/page.php new file mode 100644 index 0000000..57b1a02 --- /dev/null +++ b/packages/payables/ui/page.php @@ -0,0 +1,21 @@ +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); ?> +Obrigações a fornecedores — ensaio +
+
← Voltar à aplicação

·

Obrigações a fornecedores

ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso só reconhece o principal.

Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.

+

+

Facturas de origem

+ +
DocumentoFornecedorBaseImposto documentalTotalConferência / revisão
Origem e pendências
+

Preparar a obrigação

Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.

+

Propostas e decisões

count()): ?>

Ainda não há propostas.

+

Proposta #id) ?> · captura capture_version) ?>

Preparador #prepared_by) ?> · created_at) ?>

reason) ?>

Fotografia e pendências
snapshot) ?>
missing) ?>
+
+

Obrigações reconhecidas

Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.

+

Obrigação #

Principal reconhecido — vencimento pendente

Atraso indeterminado · Dias de atraso: sem valor

Validador # ·

Origem, versão e decisão
+

Histórico durável

#id) ?> · kind) ?> · actor #actor_id) ?> · created_at) ?>
payload) ?>
+

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.

+
diff --git a/resources/js/PurchasingBudgetWorkspace.jsx b/resources/js/PurchasingBudgetWorkspace.jsx new file mode 100644 index 0000000..04b2a7a --- /dev/null +++ b/resources/js/PurchasingBudgetWorkspace.jsx @@ -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 ; + }); + const moneyInput=(name,label,value='')=>; + return

DEMONSTRAÇÃO ORÇAMENTAL

Compras — controlo orçamental

+

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.

+

Empresa: {context.company.name} · Unidade: {context.unit?.name} · Moeda: MZN

+

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.

+ {error&&

{error}

}{notice&&

{notice}

} + + {!data?

A carregar o percurso…

:<> + {allowed('create')&&

Preparar dotação

({...d,cost_center_id:Number(d.cost_center_id),year:Number(d.year),includes_tax:d.includes_tax==='true'}))}> +
+ + + + {moneyInput('amount','Dotação inicial (MZN)','1000.00')}