Close isolated direct cost trial by delegated technical acceptance

Preserve verified 275 MZN and incomplete 175 MZN demonstrations. Keep 090000 and four real cost grants unapplied; real costing and fiscal blocks remain intact.
This commit is contained in:
2026-10-27 12:39:41 +02:00
parent ea9dcb3314
commit d10ba06edc
13 changed files with 226 additions and 4 deletions
+4
View File
@@ -1,5 +1,9 @@
# SGF Tempo — instruções locais
- Encerramento por delegação de 27/10/2026: o utilizador autorizou aceitar a entrega isolada de custos directos após conferência técnica do percurso e preservar checkpoint local. Ver `docs/WORKSHOP_DIRECT_COST_ACCEPTANCE.md`. Não se atribui nova verificação pessoal no navegador ao utilizador. `090000` e quatro concessões continuam NÃO aplicados; custeio real, emissão e restantes operações reais bloqueados. Sem CIF/lote industrial ou publicação. Esta autorização substitui apenas a pendência de encerramento técnico deste ensaio.
- Autorização técnica de 27/10/2026: implementar apenas `WORKSHOP-DIRECT-COST-SIM-v1`, secção 14 de `docs/INDUSTRIAL_COST_ACCOUNTING_SCOPE.md`. Ver `docs/WORKSHOP_DIRECT_COST_DELIVERY.md`. Apenas sintético em `sgf_tempo_test`, origens de oficina verificadas por ID/versão, sem duplicar acontecimentos. 275 MZN conhecidos ou 175 incompletos sem taxa material; exactidão racional, nunca custo completo/margem. Preparação e validação de custos/taxas por identidades distintas, sem editar apontamentos. `090000` e quatro concessões reais apenas propostos, NÃO aplicados; confirmação específica antes de aplicação. Não activar custeio real ou publicar; preservar `7628645`, `ea9dcb3`, alterações posteriores, oficinas/recebimentos/fiscalidade e aprovações. Sem presumir aceitação, CIF ou lote industrial.
- Decisões de desenho confirmadas em 27/10/2026: primeiro ensaio de custo directo de uma ordem de oficina, reutilizando apontamentos verificados por ID/versão, sem duplicar origens. 2 UN × 50,00 + 105/60 H × 100,00 = 275,00 MZN de custo directo conhecido, nunca custo completo/margem; sem taxa material, 175,00 conhecidos e total incompleto. Osmane prepara; Samira valida versões de custos/taxas sem alterar apontamentos técnicos. Futuras provas sintéticas com identidades distintas, sem novas concessões reais. Ver secção 14 de `docs/INDUSTRIAL_COST_ACCOUNTING_SCOPE.md`. Apenas decisões registadas, sem ordem de implementação; lote industrial/CIF adiados. Preservar entregas aceites e bloqueios.
- Autorização específica posterior de 27/10/2026 executada: apenas `070000` aplicada em `sgf_tempo_dev`, por caminho explícito com `sgf_dev_owner`, e oito concessões auditadas no tenant #1 / Tempo Lda #1 / SEGI #1: conta #1 `service_receivables.view/prepare/allocate/correction_request`; conta #2 `service_receivables.view/confirm/reconcile/correction_approve`. Ver actualização de `docs/SERVICE_RECEIVABLES_DELIVERY.md`. Preservadas 54 tabelas anteriores, acessos e instalação de oficinas `080000`; seis tabelas novas vazias. Aplicação mantém `sgf_dev_app`; recebimentos, reconciliação, oficinas e emissão reais continuam bloqueados na aplicação/base. Nenhuma operação real ou concessão para técnico/verificador. As indicações anteriores de `070000`/oito concessões pendentes são históricas; esta instalação não activa uso real ou publicação.
+5
View File
@@ -1,5 +1,7 @@
# SGF TEMPO
**Estado posterior em 27/10/2026:** oficinas e recebimentos sintéticos foram aceites; `080000`/quatro concessões de abertura e `070000`/oito concessões foram instaladas tecnicamente por autorizações específicas, sem retirar os bloqueios reais. As indicações de pendência nos parágrafos históricos abaixo foram substituídas pelos relatórios actualizados. O ensaio de custo directo e as suas pendências estão em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md); `090000` e quatro concessões de custos não foram aplicadas.
**Oficina de teste (`WORKSHOP-RESOURCE-RECORD-v1`):** ordem, responsável, autorização por versão da tarefa, apontamentos de tempos/materiais e verificação distinta do autor. Só funciona em instância isolada ligada a `sgf_tempo_test`. Abrir a ordem não autoriza executar; ocorrência passada pode ser relatada e reconhecida apenas para auditoria, conservando a marca de fora do âmbito. Comandos, percurso e provas em [WORKSHOP_RESOURCE_DELIVERY.md](docs/WORKSHOP_RESOURCE_DELIVERY.md). Migração `080000` e concessões reais não aplicadas; técnico/verificador reais ainda não indicados. A migração `070000` também permanece pendente: não usar migração geral para aplicar ambas sem autorização específica. Sem stock, custos, facturação ou utilização operacional. Não confundir esta entrega técnica com aceitação do utilizador ou de recebimentos.
**Recebimentos simulados (`SGF-RECEIVABLES-SIM-v1`):** implementação exclusivamente de teste, com obrigação explícita, recebimentos parciais, afectações, conferência simulada e correcções rastreáveis. Percurso, comandos e provas em [SERVICE_RECEIVABLES_DELIVERY.md](docs/SERVICE_RECEIVABLES_DELIVERY.md). Migração `070000` e oito concessões reais permanecem pendentes de confirmação específica; não executar a migração normal para as aplicar sem essa confirmação. A demonstração usa apenas `sgf_tempo_test`, login sintético e porta 8001; a aplicação normal não muda de ligação. Recuperação de resposta perdida usa a mesma chave de comando; não repetir criando outro acontecimento. Sem recebimentos reais, recibo fiscal, novo PDF, banco, lançamento ou fecho. Emissão/configuração real continuam bloqueadas. Entrega técnica ainda sujeita à revisão do utilizador.
@@ -113,3 +115,6 @@ Para Vite, `sgf.ps1 frontend` (porta 5173, estrita). A aplicação continua a se
O primeiro núcleo de cálculo de serviço e a política operacional de truncagem estão documentados em [cálculo de serviço: utilização, provas e limites](docs/SERVICE_CALCULATION_DELIVERY.md). A aplicação normal conserva pendências de aplicabilidade; cálculos de ensaio são exclusivos da instância isolada de testes. Não há activação de perfil fiscal real, emissão, recebimento ou contabilização.
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
O percurso `WORKSHOP-DIRECT-COST-SIM-v1` e as instruções de prova estão em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md). Reutiliza apontamentos verificados sem duplicar consumos; não activa custeio real. Migração `090000` e quatro concessões continuam propostas, não aplicadas.
+10
View File
@@ -0,0 +1,10 @@
# Encerramento isolado — WORKSHOP-DIRECT-COST-SIM-v1
27/10/2026, Africa/Johannesburg. O utilizador autorizou o encerramento por delegação, condicionado à conferência do percurso e satisfação técnica, mantendo `090000` e quatro concessões por aplicar. O percurso foi conferido através da demonstração e provas já preservadas: 275 MZN de custo directo conhecido, variante de 175 MZN incompletos, identidades sintéticas distintas, histórico, concorrência e recuperação. Não se declara uma nova verificação pessoal do utilizador no navegador.
Aceitação limitada à entrega isolada de [WORKSHOP_DIRECT_COST_DELIVERY.md](WORKSHOP_DIRECT_COST_DELIVERY.md), com 49 testes / 1 010 asserções aprovados, seis provas concorrentes e compilação concluída. Não se repetiram testes por esta alteração exclusivamente documental. O campo `user_acceptance: false` nas provas anteriores descreve o estado da entrega técnica antes deste encerramento e não é reescrito.
- Migração `090000` não aplicada em `sgf_tempo_dev`; quatro concessões reais apenas propostas para confirmação específica.
- Custeio operacional real, emissão e restantes operações reais continuam bloqueados; sem stock, custos indirectos, lote industrial, lançamentos ou publicação.
- Preservados `7628645`, `ea9dcb3`, oficinas, recebimentos, cálculo fiscal e aprovações existentes. Não se reabre a aceitação dessas entregas.
- O commit que contém este registo é o checkpoint local. Inclui fontes, testes, documentação e provas sintéticas; não inclui credenciais ou dados operacionais reais novos. Não autoriza push ou outro incremento.
+85
View File
@@ -0,0 +1,85 @@
# WORKSHOP-DIRECT-COST-SIM-v1 — entrega técnica isolada
27/10/2026, Africa/Johannesburg. Implementa apenas o ensaio aprovado na secção 14 de [INDUSTRIAL_COST_ACCOUNTING_SCOPE.md](INDUSTRIAL_COST_ACCOUNTING_SCOPE.md). **Sem aceitação presumida, custeio real, publicação ou avanço para lote industrial/CIF.** Preservados `7628645`, oficinas, recebimentos, B1, motor exacto e aprovações. O commit posterior `ea9dcb3`, observado durante a execução e não criado por este procedimento, também foi preservado.
## Funcionalidade
- Reutiliza os apontamentos técnicos verificados de uma ordem por ID e versão. Não insere ou altera acontecimentos em `wo_*` ao propor/validar custos. A fotografia do cálculo preserva a composição derivada, não um segundo consumo ou tempo operacional.
- Taxas sintéticas com identificador, versão anterior explícita, origem técnica exacta, ordem/empresa/unidade, MZN, unidade e evidência. Zero expressamente indicado é válido; ausência é pendência. Não há procura automática da taxa mais recente: cada linha recebe ID e versão escolhidos ou ausência explícita. Identificadores alternativos podem coexistir e exigem escolha explícita.
- Preparador propõe taxas e cálculos; validador distinto aprova/recusa a versão exacta. Uma aprovação de cálculo incompleto valida só a composição declarada e conserva pendências, nunca a transforma em custo completo. A validação não altera decisões ou apontamentos técnicos.
- Cálculo limitado a duas origens seleccionadas: um consumo efectivo da empresa e um apontamento de trabalho efectivo. Entrega/devolução/requisição, deslocação, material do cliente, origem fora do âmbito ou não verificada são recusados. Não desconta novamente a devolução do consumo já observado.
- Aritmética `Brick\Math` exacta existente; conversão de MIN para H por divisão explícita por 60. Decimais finitos são apresentados exactamente; divisão não finita conserva fracção racional, sem impor arredondamento. Não se reutiliza a política de IVA. Entradas de taxa são cadeias decimais não negativas, até 16 algarismos inteiros/8 decimais, sem float ou notação exponencial.
- Versões novas preservam histórico. Correcção de origem/tarefa ou nova versão da taxa usada torna o cálculo desactualizado; nova versão de cálculo torna a anterior histórica. O conjunto de IDs de tempo/material da ordem também integra a dependência: acréscimos exigem revisão. Taxa de outro identificador não substitui a escolhida silenciosamente. Propostas/refusas de taxa sucessora suspendem conservadoramente o uso actual da versão anterior, sem alterar o seu histórico.
## Resultados demonstrados
| Caso | Composição | Resultado e limite |
| --- | --- | --- |
| Taxas identificadas e validadas | 2 UN × 50 + 105 MIN ÷ 60 × 100 | 100 + 175 = **275 MZN de custo directo conhecido**, nunca custo completo/margem |
| Taxa material ausente | Material pendente; 105/60 × 100 | **175 MZN conhecidos**, total seleccionado indisponível/incompleto e pendência explícita |
| Taxa material zero explícita, teste automatizado | 2 × 0 + 105/60 × 100 | 175 no perímetro directo, taxa zero distinguida de ausência |
| Divisão não finita, teste automatizado | 1 MIN ÷ 60 × 1 MZN/H | **1/60 MZN exacto**, sem truncagem ou cêntimo inventado |
Valorizado o perímetro seleccionado não significa que todos os custos da ordem estejam conhecidos. Sem salários reais, preços de venda, margens, custos indirectos, reconhecimento, stock ou valorização legal de inventário.
## Segurança, versões e recuperação
Novos direitos explícitos `direct_costs.view/prepare/validate`, sempre na unidade; não herdados de oficinas, emissão ou administração. Consulta de custos é separada do acesso técnico. Contexto, pertenças e direito específico são revalidados em cada comando e replay, inclusive dentro da transacção. Auto-aprovação é recusada mesmo com ambas as permissões. Identidades reais nunca são reconhecidas por nome/email no código funcional.
Transacção e bloqueio da empresa serializam preparação/validação/alterações técnicas; versões anteriores explícitas impedem duas sucessoras incompatíveis. A chave idempotente inclui actor/acção/contexto/conteúdo; resposta perdida recupera o ID, conteúdo diferente é conflito. Recalcular composição idêntica à última versão pode devolver o mesmo resultado, sem duplicar fotografia. Comando, auditoria, evento versionado e outbox são atómicos; falha de outbox desfaz a operação. Não há consumidor contabilístico. A outbox permanece `captured_no_accounting`.
FKs/CHECKs/triggers validam contexto, referências, verificação técnica, unidades, cadeias de versões, taxas aprovadas e separação de autores. As seis tabelas são append-only, incluindo contra UPDATE/DELETE em massa. O núcleo de composição exacta e a autorização autenticada são da aplicação; os triggers não são RLS nem substituem essas verificações. SQL administrativo é uma fronteira de confiança distinta. O bloqueio SQL de ambiente só admite `sgf_tempo_test` na porta 55432; a aplicação exige ainda ambiente isolado e `TestingDatabaseGuard` com papel `sgf_test_runner`.
## Provas e repetição
Provas: [resultados do navegador](proofs/direct-cost-browser-results.json), [275 MZN](proofs/direct-cost-275-browser.png), [175 MZN incompletos](proofs/direct-cost-partial-browser.png), [concorrência](proofs/direct-cost-concurrency.json) e [testes finais](proofs/direct-cost-tests.json).
Resultado final: **49 testes / 1 010 asserções, zero erros/falhas**, incluindo oito casos de custos directos. **Seis provas concorrentes aprovadas**. Compilação aprovada com Vite 8.3.5 e versões/dependências preservadas. Não foi necessária repetição da investigação fiscal. A instância do navegador foi parada antes da regressão final.
Percurso efectivamente realizado no Edge, `http://127.0.0.1:8001`, sessão/cache isolados e CSRF activo:
1. Preparador `gestor@example.test`: login real, Empresa Horizonte — Demonstração / Unidade Central — Demonstração → **Custos directos de teste**. Referências técnicas #4 v1 (105 MIN) e #6 v1 (2 UN) nesta execução; IDs não são fixos.
2. Propôs `TEST-TRABALHO` v1 a 100 MZN/H e `TEST-MATERIAL` v1 a 50 MZN/UN, com evidência sintética.
3. Validador `consulta@example.test`: login real distinto, mesmo contexto; validou as duas taxas. Não dispõe de formulário de preparação nem acesso técnico por consequência.
4. Preparador entrou novamente, escolheu explicitamente as duas taxas e calculou 275 MZN. Validador aprovou a versão exacta com fundamento de custo directo conhecido.
5. Preparador criou nova versão sem taxa material. Validador aprovou só os 175 MZN conhecidos com pendência. O resultado de 275 continuou preservado e identificado como histórico.
A fixture apenas criou/verificou fontes técnicas pelos comandos existentes da oficina, com relógio sintético local no processo para a autorização histórica e restaurado no fim. Não mudou relógio do sistema, ligação normal ou regras técnicas. Sete apontamentos de origem permaneceram sete após os cálculos. Palavras-passe aleatórias não são apresentadas ou versionadas.
Seis cenários com dois processos realmente sobrepostos: mesma taxa; mesma decisão; taxas sucessoras concorrentes; mesmo cálculo; cálculos concorrentes incompatíveis; cálculo novo versus aprovação antiga. Provas adicionais automatizadas: revogação no pedido seguinte/replay, identidades/contextos/versões manipulados, moedas/unidades/precisão, origem corrigida e ainda não verificada, imutabilidade SQL, recusa, falta versus zero e falha/recuperação de outbox. A regressão relevante inclui oficinas, recebimentos, B1 e guardas de testes.
Repetir exclusivamente em testes, sem instância de prova em execução durante os testes destrutivos:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test '--filter=DirectCostsTest|WorkshopTest|ServiceReceivablesTest|InternalConfirmationTest|DatabaseSafetyTest'
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
```
Para o navegador, preparar uma base de testes vazia sob as guardas existentes, executar `scripts/browser-proof.ps1 start` (prepara a fundação sintética), depois `sgf.ps1 php scripts/direct-cost-fixture.php --env=testing`. O preparador recusa estado preenchido e não apaga dados. Seguir o percurso acima com a credencial no manifesto local protegido. Depois do percurso, `sgf.ps1 php scripts/direct-cost-proof.php --env=testing` e `sgf.ps1 php scripts/direct-cost-concurrency.php --env=testing`. Parar com `scripts/browser-proof.ps1 stop`; não terminar processos alheios para libertar portas. Os testes finais recriam só `sgf_tempo_test`, preservando as provas em ficheiros.
## Migração proposta — NÃO aplicada em desenvolvimento
**`2026_10_27_090000_create_workshop_direct_costs.php`**, SQL `database/sql/direct-costs.sql`, destino futuro **`sgf_tempo_dev`**, apenas após confirmação específica.
Seis tabelas novas: `dc_rates`, `dc_calculations`, `dc_decisions`, `dc_commands`, `dc_events`, `dc_outbox`; FKs, índices, versões únicas, funções/triggers de integridade, ambiente e imutabilidade. Amplia o CHECK de concessões com três nomes de unidade, preservando alternativas existentes. Não concede acessos, não altera apontamentos ou tabelas de recebimentos/fiscalidade. `down()` recusa apagar história. Sem reset/fresh em desenvolvimento. Mesmo instalada futuramente, a aplicação e SQL continuam a impedir custeio operacional nessa base.
## Concessões propostas — separadas, NÃO aplicadas
Conferidas por leitura em `scripts/direct-cost-permissions-preview.php`, sem modo de aplicação. Todas ausentes na conferência de 27/10/2026. Âmbito exacto: **tenant #1 / Tempo Lda #1 / SEGI #1**.
| Conta existente | Proposta |
| --- | --- |
| Osmane Nala #1, nala@tempo.co.mz | `direct_costs.view`, `direct_costs.prepare` |
| Samira Nal #2, onala@me.com | `direct_costs.view`, `direct_costs.validate` |
São **quatro concessões propostas**, não autorização de aplicação ou activação real. Sem gestão de acessos, supervisão técnica, alteração dos apontamentos, salários, margem, stock ou emissão. Só as identidades equivalentes sintéticas receberam estes direitos nos testes. Técnico/verificador reais continuam sem concessões novas.
## Encerramento posterior por delegação
Em 27/10/2026, o utilizador autorizou o encerramento após conferência técnica do percurso e preservação de checkpoint local. Ver [registo de encerramento e limites](WORKSHOP_DIRECT_COST_ACCEPTANCE.md). As referências anteriores a ausência de aceitação presumida descrevem a entrega antes desta autorização; `090000`, quatro concessões e uso operacional real continuam por autorizar/aplicar.
## Ficheiros e limites
Núcleo `app/Modules/Workshop/DirectCosts.php`, controlador `Http/DirectCostsController.php`, interface `resources/js/DirectCostsWorkspace.jsx`, integrações em `app.jsx` e `routes/web.php`; migração/SQL acima; `tests/Feature/DirectCostsTest.php`, `tests/Support/DirectCostFixture.php`; scripts `direct-cost-fixture.php`, `direct-cost-worker.php`, `direct-cost-concurrency.php`, `direct-cost-proof.php`, `direct-cost-permissions-preview.php`; esta documentação e provas. Não se alteraram ferramentas/dependências ou o motor fiscal.
Recorte deliberado de duas origens, uma moeda e uma ordem, sem partilhas entre ordens, custos indirectos, lote, produção em curso, margens, stock ou facturação. Taxas ligadas à versão exacta da origem exigem nova taxa validada para uma correcção técnica; não há tabela universal de salários/custos. Sem PDF/exportação, assinatura digital, activação real ou aceitação presumida. As fotografias/eventos anteriores e todos os bloqueios permanecem preservados.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,35 @@
{
"policy": "WORKSHOP-DIRECT-COST-SIM-v1",
"database": "sgf_tempo_test",
"synthetic_only": true,
"real_browser_login": true,
"csrf_enabled": true,
"distinct_cost_roles": true,
"original_workshop_entries": 7,
"technical_events_not_duplicated": true,
"entry_versions": [
{
"id": 4,
"version": 1
},
{
"id": 6,
"version": 1
}
],
"full_selected_direct": {
"material": "100",
"labour": "175",
"known_subtotal": "275",
"complete_cost_claimed": false
},
"missing_material_rate": {
"known_subtotal": "175",
"selected_total": null,
"incomplete": true,
"pending": true
},
"both_exact_versions_approved": true,
"real_grants_applied": false,
"development_migration_applied": false
}
+13
View File
@@ -0,0 +1,13 @@
{
"database": "sgf_tempo_test",
"overlapping_processes_per_case": 2,
"checks": {
"rate_repetition_one_version": true,
"decision_repetition_one_effect": true,
"competing_rate_versions_one_winner": true,
"calculation_repetition_one_snapshot": true,
"competing_calculations_one_revision": true,
"new_calculation_refuses_old_approval": true
},
"synthetic_only": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"policy": "WORKSHOP-DIRECT-COST-SIM-v1",
"date": "2026-10-27",
"database": "sgf_tempo_test",
"filter": "DirectCostsTest|WorkshopTest|ServiceReceivablesTest|InternalConfirmationTest|DatabaseSafetyTest",
"tests": 49,
"assertions": 1010,
"failures": 0,
"errors": 0,
"direct_cost_test_cases": 8,
"overlapping_process_cases": 6,
"junit_local_ignored": "storage/app/direct-cost-tests.xml",
"build": {"command": "scripts/sgf.ps1 npm run build", "vite": "8.3.5", "passed": true},
"browser_real_login": true,
"browser_server_stopped_before_final_tests": true,
"development_migration_090000_applied": false,
"real_cost_grants_applied": false,
"operational_and_fiscal_blocks_preserved": true,
"fiscal_and_workshop_core_unchanged_from": "7628645",
"user_acceptance": false
}
+26
View File
@@ -0,0 +1,26 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Modules\Workshop\DirectCosts;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
DirectCosts::guard();$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);$fx=$f['direct_costs'];$proof=[];$key=fn()=>(string)Str::uuid();
$base=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'order'=>$fx['order']];
$pair=function($one,$two)use($base){
$a=new Process([PHP_BINARY,'scripts/direct-cost-worker.php','--env=testing'],base_path(),null,json_encode($one+$base+['pause'=>true]));$b=new Process([PHP_BINARY,'scripts/direct-cost-worker.php','--env=testing'],base_path(),null,json_encode($two+$base));
$a->start();if(!$a->waitUntil(fn($t,$v)=>str_contains($v,'LOCK_HELD')))throw new RuntimeException('Concurrent lock not observed.');$b->start();$a->wait();$b->wait();$out=[];
foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Cost worker failed.');$lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($v)=>str_starts_with($v,'{')));$out[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);}return $out;
};
$check=function($out,bool $replay){if($out[0]['status']!==200||$out[1]['status']!==($replay?200:409)||($replay&&(!$out[1]['result']['replayed']||$out[0]['result']['id']!==$out[1]['result']['id'])))throw new RuntimeException('Unexpected concurrent outcome.');};
$rate=['method'=>'rate','actor'=>$f['manager_id'],'key'=>$key(),'input'=>['entry_id'=>$fx['material'],'entry_version'=>1,'identifier'=>'TEST-CONCURRENT-'.Str::uuid(),'version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>'UN','amount'=>'1','evidence'=>'Taxa sintética concorrente.']];
$out=$pair($rate,$rate);$check($out,true);$proof['rate_repetition_one_version']=true;$rid=$out[0]['result']['id'];
$decision=['method'=>'decide','actor'=>$f['reader_id'],'key'=>$key(),'input'=>['kind'=>'rate','id'=>$rid,'version'=>1,'decision'=>'approved','reason'=>'Validação sintética concorrente.']];$check($pair($decision,$decision),true);$proof['decision_repetition_one_effect']=true;
$one=$rate;$one['key']=$key();$one['input']['version']=2;$one['input']['previous_id']=$rid;$two=$one;$two['key']=$key();$two['input']['amount']='2';$check($pair($one,$two),false);$proof['competing_rate_versions_one_winner']=true;
$rates=DB::table('dc_rates')->where('order_id',$fx['order'])->whereIn('identifier',['TEST-TRABALHO','TEST-MATERIAL'])->get()->keyBy('identifier');
$lines=[['entry_id'=>$fx['time'],'entry_version'=>1,'rate_id'=>$rates['TEST-TRABALHO']->id,'rate_version'=>1],['entry_id'=>$fx['material'],'entry_version'=>1,'rate_id'=>$rates['TEST-MATERIAL']->id,'rate_version'=>1]];
$last=DB::table('dc_calculations')->where('order_id',$fx['order'])->orderByDesc('version')->first();
$calc=['method'=>'calculate','actor'=>$f['manager_id'],'key'=>$key(),'input'=>['currency'=>'MZN','version'=>$last->version+1,'previous_id'=>$last->id,'lines'=>$lines]];$out=$pair($calc,$calc);$check($out,true);$proof['calculation_repetition_one_snapshot']=true;
$last=DB::table('dc_calculations')->where('order_id',$fx['order'])->orderByDesc('version')->first();$calc['key']=$key();$calc['input']['version']=$last->version+1;$calc['input']['previous_id']=$last->id;$calc['input']['lines'][1]['rate_id']=null;$calc['input']['lines'][1]['rate_version']=null;$two=$calc;$two['key']=$key();$two['input']['lines']=$lines;$two['input']['lines'][0]['rate_id']=null;$two['input']['lines'][0]['rate_version']=null;$out=$pair($calc,$two);$check($out,false);$proof['competing_calculations_one_revision']=true;
$last=DB::table('dc_calculations')->where('order_id',$fx['order'])->orderByDesc('version')->first();$calc['key']=$key();$calc['input']['version']=$last->version+1;$calc['input']['previous_id']=$last->id;$calc['input']['lines']=$lines;
$oldDecision=['method'=>'decide','actor'=>$f['reader_id'],'key'=>$key(),'input'=>['kind'=>'calculation','id'=>$last->id,'version'=>$last->version,'decision'=>'approved','reason'=>'Decisão de uma versão agora antiga.']];$check($pair($calc,$oldDecision),false);$proof['new_calculation_refuses_old_approval']=true;
file_put_contents(base_path('docs/proofs/direct-cost-concurrency.json'),json_encode(['database'=>'sgf_tempo_test','overlapping_processes_per_case'=>2,'checks'=>$proof,'synthetic_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Six overlapping-process direct cost scenarios passed.\n";
@@ -0,0 +1,12 @@
<?php
// Read-only. No apply branch; four prospective cost grants only.
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows())throw new RuntimeException('Own SGF development database required.');
$db=\Illuminate\Support\Facades\DB::class;$grants=[];
foreach([1=>['view','prepare'],2=>['view','validate']] as $user=>$rights){
$u=$db::table('users')->where('id',$user)->first(['id','name','email']);if(!$u||!$db::table('company_memberships')->where(['user_id'=>$user,'tenant_id'=>1,'company_id'=>1])->exists())throw new RuntimeException('Existing account/member required.');
foreach($rights as $r){$permission='direct_costs.'.$r;$scope=['user_id'=>$user,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$permission];$grants[]=$scope+['account'=>$u,'already_granted'=>$db::table('permission_grants')->where($scope)->exists()];}
}
$company=$db::table('companies')->where(['id'=>1,'tenant_id'=>1])->value('name');$unit=$db::table('units')->where(['id'=>1,'company_id'=>1,'tenant_id'=>1])->value('name');if($company!=='Tempo Lda'||$unit!=='SEGI')throw new RuntimeException('Expected scope differs.');
try{\App\Modules\Workshop\DirectCosts::guard();throw new RuntimeException('Real costing not blocked.');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==403)throw $e;}
echo json_encode(['mode'=>'preview_only','company'=>$company,'unit'=>$unit,'proposed_grants'=>$grants,'applied'=>false,'real_costing_blocked'=>true,'migration_090000_applied'=>$db::table('migrations')->where('migration','2026_10_27_090000_create_workshop_direct_costs')->exists()],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR).PHP_EOL;
+10
View File
@@ -0,0 +1,10 @@
<?php
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\DirectCosts::guard();$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);$fx=$f['direct_costs'];$db=\Illuminate\Support\Facades\DB::class;
$rows=$db::table('dc_calculations')->where('order_id',$fx['order'])->orderBy('id')->get();if($rows->count()<2)throw new RuntimeException('Two browser calculations required.');
$full=json_decode($rows[0]->snapshot,true);$partial=json_decode($rows[1]->snapshot,true);
if($full['known_subtotal_exact']!=='275'||$full['incomplete']||$partial['known_subtotal_exact']!=='175'||!$partial['incomplete']||$partial['selected_direct_total_exact']!==null||count($partial['pending'])!==1)throw new RuntimeException('Browser arithmetic/completeness differs.');
foreach([$rows[0],$rows[1]] as $c){$decision=$db::table('dc_decisions')->where(['calculation_id'=>$c->id,'decision'=>'approved'])->first();if(!$decision||$decision->actor_id===$c->actor_id)throw new RuntimeException('Distinct approval required.');}
if($db::table('wo_entries')->where('order_id',$fx['order'])->count()!==7)throw new RuntimeException('Technical events duplicated.');
$proof=['policy'=>\App\Modules\Workshop\DirectCosts::POLICY,'database'=>'sgf_tempo_test','synthetic_only'=>true,'real_browser_login'=>true,'csrf_enabled'=>true,'distinct_cost_roles'=>true,'original_workshop_entries'=>7,'technical_events_not_duplicated'=>true,'entry_versions'=>[['id'=>$fx['time'],'version'=>1],['id'=>$fx['material'],'version'=>1]],'full_selected_direct'=>['material'=>'100','labour'=>'175','known_subtotal'=>'275','complete_cost_claimed'=>false],'missing_material_rate'=>['known_subtotal'=>'175','selected_total'=>null,'incomplete'=>true,'pending'=>true],'both_exact_versions_approved'=>true,'real_grants_applied'=>false,'development_migration_applied'=>false];
file_put_contents(base_path('docs/proofs/direct-cost-browser-results.json'),json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Browser results verified: 275 known, 175 incomplete, distinct approval, seven original technical entries unchanged in number.\n";
+5 -4
View File
@@ -15,8 +15,8 @@ final class DirectCostsTest extends TestCase {
protected function setUp(): void {parent::setUp();$this->f=DirectCostFixture::create();$this->login();}
private function login(string $who='manager'): void {$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');}
private function send(string $action,array $d,?string $key=null,?int $order=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/direct-costs/orders/'.($order??$this->f['order']).'/'.$action,$d);}
private function rate(string $kind,string $amount='50',array $extra=[]){return $this->send('rates',array_replace(['entry_id'=>$this->f[$kind],'entry_version'=>1,'identifier'=>'TEST-'.$kind,'version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>$kind==='time'?'H':'UN','amount'=>$amount,'evidence'=>'Taxa sintética, não custo real.'],$extra));}
private function decide(string $kind,int $id,array $extra=[]){$r=DB::table($kind==='rate'?'dc_rates':'dc_calculations')->find($id);return $this->send('decisions',array_replace(['kind'=>$kind,'id'=>$id,'version'=>$r->version,'decision'=>'approved','reason'=>'Revisão sintética de custos e taxas.'],$extra));}
private function rate(string $kind,string $amount='50',array $extra=[]){return $this->send('rates',array_replace(['entry_id'=>$this->f[$kind],'entry_version'=>1,'identifier'=>'TEST-'.$kind,'version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>$kind==='time'?'H':'UN','amount'=>$amount,'evidence'=>'Taxa sintética, não custo real.'],$extra));}
private function decide(string $kind,int $id,array $extra=[]){$r=DB::table($kind==='rate'?'dc_rates':'dc_calculations')->find($id);return $this->send('decisions',array_replace(['kind'=>$kind,'id'=>$id,'version'=>$r->version,'decision'=>'approved','reason'=>'Revisão sintética de custos e taxas.'],$extra));}
private function rates(): array {$m=$this->rate('material')->assertOk()->json('id');$t=$this->rate('time','100')->assertOk()->json('id');$this->login('reader');$this->decide('rate',$m)->assertOk();$this->decide('rate',$t)->assertOk();$this->login();return [$m,$t];}
private function input(?int $m,?int $t,array $extra=[]): array {return array_replace(['currency'=>'MZN','version'=>1,'previous_id'=>null,'lines'=>[['entry_id'=>$this->f['material'],'entry_version'=>1,'rate_id'=>$m,'rate_version'=>$m?1:null],['entry_id'=>$this->f['time'],'entry_version'=>1,'rate_id'=>$t,'rate_version'=>$t?1:null]]],$extra);}
private function listing(){return $this->getJson('/api/direct-costs')->assertOk();}
@@ -24,6 +24,7 @@ final class DirectCostsTest extends TestCase {
public function test_exact_known_cost_and_distinct_validation_without_new_consumption(): void {
$before=DB::table('wo_entries')->get()->toJson();[$m,$t]=$this->rates();$id=$this->send('calculations',$this->input($m,$t))->assertOk()->json('id');$s=$this->snapshot($id);
$this->assertSame('275',$s['known_subtotal_exact']);$this->assertSame('275',$s['selected_direct_total_exact']);$this->assertFalse($s['incomplete']);$this->assertSame(['175','100'],array_column($s['lines'],'cost_exact'));$this->assertSame('1.75',$s['lines'][0]['cost_quantity_exact']);
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.validate',$this->f['unit']);
$this->decide('calculation',$id)->assertForbidden();$this->login('reader');$this->decide('calculation',$id)->assertOk();$this->assertSame($before,DB::table('wo_entries')->get()->toJson());
$this->assertSame(DB::table('dc_commands')->count(),DB::table('dc_events')->count());$this->assertSame(DB::table('dc_events')->count(),DB::table('dc_outbox')->count());
}
@@ -44,7 +45,7 @@ final class DirectCostsTest extends TestCase {
foreach(['','1.000000001','-1','1e2','1,00'] as $value)$this->rate('material',$value)->assertStatus(422);
$this->rate('material','50',['amount'=>50])->assertStatus(422);
$e=DB::table('wo_entries')->find($this->f['material']);$d=json_decode($e->data,true);unset($d['policy']);$rev=(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v;
app(CompanyContext::class)->selectFor($this->f['technician']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(WorkshopWorkflow::class)->record($e->order_id,$this->f['technician']->id,array_replace($d,['version'=>2,'previous_id'=>$e->id,'revision'=>$rev,'quantity'=>'2.1','correction_reason'=>'Correcção sintética de quantidade.']),(string)Str::uuid());$this->login();$this->rate('material')->assertStatus(409);$latest=DB::table('wo_entries')->where('previous_id',$e->id)->first();$this->rate('material','50',['entry_id'=>$latest->id,'entry_version'=>2])->assertStatus(409);
app(CompanyContext::class)->selectFor($this->f['technician']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(WorkshopWorkflow::class)->record($e->order_id,$this->f['technician']->id,array_replace($d,['version'=>2,'previous_id'=>$e->id,'revision'=>$rev,'quantity'=>'2.1','correction_reason'=>'Correcção sintética de quantidade.']),(string)Str::uuid());$this->login();$this->rate('material')->assertStatus(409);$latest=DB::table('wo_entries')->where('previous_id',$e->id)->first();$this->rate('material','50',['entry_id'=>$latest->id,'entry_version'=>2])->assertStatus(409);
}
public function test_roles_scope_revocation_and_idempotent_response_recovery(): void {
$key=(string)Str::uuid();$d=['entry_id'=>$this->f['material'],'entry_version'=>1,'identifier'=>'TEST-REPLAY','version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>'UN','amount'=>'50','evidence'=>'TEST-REPLAY'];$id=$this->send('rates',$d,$key)->assertOk()->json('id');$this->send('rates',$d,$key)->assertOk()->assertJsonPath('replayed',true)->assertJsonPath('id',$id);$this->send('rates',array_replace($d,['amount'=>'51']),$key)->assertStatus(409);
@@ -64,7 +65,7 @@ final class DirectCostsTest extends TestCase {
$e=DB::table('wo_entries')->find($this->f['time']);$d=json_decode($e->data,true);unset($d['policy']);$d=array_replace($d,['version'=>2,'previous_id'=>$e->id,'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v,'ends_at'=>$this->f['day'].'T09:01:00+02:00','pauses'=>[],'correction_reason'=>'Um minuto no novo ensaio exacto.']);
app(CompanyContext::class)->selectFor($this->f['technician']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$w=app(WorkshopWorkflow::class);$next=$w->record($e->order_id,$this->f['technician']->id,$d,(string)Str::uuid())['id'];
$this->login('reader');$this->decide('calculation',$id)->assertStatus(409);
app(CompanyContext::class)->selectFor($this->f['reviewer']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$w->verify($next,$this->f['reviewer']->id,['version'=>2,'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v,'decision'=>'verified','reason'=>'Verificação técnica da correcção.'],(string)Str::uuid());$this->login();
app(CompanyContext::class)->selectFor($this->f['reviewer']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$w->verify($next,$this->f['reviewer']->id,['version'=>2,'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v,'decision'=>'verified','reason'=>'Verificação técnica da correcção.'],(string)Str::uuid());$this->login();
$newRate=$this->rate('time','1',['entry_id'=>$next,'entry_version'=>2,'version'=>2,'previous_id'=>$t])->assertOk()->json('id');$this->login('reader');$this->decide('rate',$newRate)->assertOk();$this->login();$in=$this->input(null,$newRate,['version'=>2,'previous_id'=>$id]);$in['lines'][1]=['entry_id'=>$next,'entry_version'=>2,'rate_id'=>$newRate,'rate_version'=>2];$result=$this->send('calculations',$in)->assertOk()->json('id');$this->assertSame('1/60',$this->snapshot($result)['known_subtotal_exact']);$this->assertSame($original,DB::table('dc_calculations')->find($id)->snapshot);
// Own tenant does not imply sibling company or hidden unit access.
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'unit.view',$this->f['hidden']);IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.view',$this->f['hidden']);IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.prepare',$this->f['hidden']);