Accept isolated WORKSHOP-RESOURCE-RECORD-v1 delivery

Record explicit workshop acceptance and preserve subsequent synthetic work without accepting receivables. Keep 080000/070000 unapplied, real workshop grants pending and operational issuance blocks intact. Exclude local credentials and real-data preservation reports.
This commit is contained in:
2026-10-27 11:28:34 +02:00
parent 4276aca5c2
commit 21a91f2aff
47 changed files with 2774 additions and 6 deletions
+8
View File
@@ -1,5 +1,13 @@
# SGF Tempo — instruções locais
- Aceitação específica de 27/10/2026: o utilizador aceitou a entrega isolada `WORKSHOP-RESOURCE-RECORD-v1` e autorizou o registo e checkpoint Git exclusivamente local, sem credenciais/dados reais. Ver `docs/WORKSHOP_RESOURCE_ACCEPTANCE.md`. Esta aceitação não aplica `080000`, não concede direitos, não activa uso operacional e não abrange a aceitação de recebimentos. `070000` permanece separada e pendente. Técnico/verificador reais continuam por identificar. Sem custos industriais, publicação ou novo incremento. Esta aceitação substitui apenas a anterior pendência de aceitação de oficinas.
- Autorização de 27/10/2026: `WORKSHOP-RESOURCE-RECORD-v1`, ordem/autorização/tarefas/tempos/materiais/verificação, exclusivamente sintético em `sgf_tempo_test`. Ver `docs/WORKSHOP_RESOURCE_DELIVERY.md`. Início de trabalho exige autorização; relato de ocorrência passada fora do âmbito é permitido para auditoria, permanentemente sinalizado, sem autorização retroactiva ou verificação automática. Verificador distinto do autor. Migração `080000` e concessões reais NÃO aplicadas; confirmação específica antes de qualquer aplicação. `070000` de recebimentos continua pendente e não deve ser aplicada por arrasto. Técnico/verificador reais não identificados; não criar contas reais ou atribuir supervisão à Samira. Preservar entregas, sem presumir aceitação de recebimentos/oficina. Sem stock/custos/facturação/recebimentos/lançamentos ou publicação; guardas operacionais/fiscais mantidas.
- Autorização de 27/10/2026: implementar `SGF-RECEIVABLES-SIM-v1`, conforme `docs/SERVICE_RECEIVABLES_SCOPE.md`, secção 12, exclusivamente sintético em `sgf_tempo_test`. Ver `docs/SERVICE_RECEIVABLES_DELIVERY.md`. Preservar `4276aca` e alterações posteriores. Migração `2026_10_27_070000` NÃO aplicada em desenvolvimento e oito concessões reais NÃO aplicadas: exigem confirmação específica antes da aplicação. Manter guardas de ambiente/base, original imutável, montantes exactos, identidade distinta na confirmação/aprovação e revisão exacta. Sem recebimentos reais, recibo fiscal, ligação bancária, dinheiro, lançamento, fecho ou publicação. Não retirar bloqueio de emissão/configuração real. Não declarar aceitação automática desta entrega.
- Autorização específica posterior de 27/10/2026 executada: migração `2026_10_26_060000_create_test_invoice_issuance.php` aplicada em `sgf_tempo_dev` e oito concessões exactamente propostas aplicadas/auditadas (conta #1: série view/propose na empresa #1, documento view/issue/correction_request só unidade #1; conta #2: série view/approve na empresa #1, documento view só unidade #1). Ver secção de aplicação em `docs/SERVICE_INVOICE_TEST_DELIVERY.md` e provas `invoice-dev-preservation*.json`. Preservadas 31 tabelas preexistentes e acessos anteriores; oito tabelas novas vazias. Nenhuma série real ou documento criado. `ServiceInvoicing::guard()` permanece inalterado e as entradas de emissão/configuração real recusam com 403 mesmo com concessões. A autorização não retira o bloqueio, não resolve conformidade e não autoriza emissão, recebimentos, lançamentos ou publicação. As indicações anteriores de migração/concessões pendentes são históricas.
- Em 27/10/2026, o utilizador confirmou **«Aceitar a entrega técnica e criar checkpoint local»** para navegador + PDF, após verificar a demonstração. Entrega técnica encerrada no âmbito de `docs/SERVICE_INVOICE_TEST_DELIVERY.md`, exclusivamente de teste. Registar/preservar checkpoint local sem credenciais/dados reais; não repetir validações aceites sem alteração/falha relevante. Esta aceitação não aplica a migração `060000`, oito concessões ou série real, não resolve conformidade/exigibilidade/apuramento e não autoriza retirar o bloqueio de emissão real, emitir, receber, contabilizar ou publicar.
- Autorização posterior de 26/10/2026: incremento técnico de emissão/reprodução no navegador e PDF, **exclusivamente demonstrável em sgf_tempo_test**, com bloqueio incondicional de emissão real. Ver `docs/SERVICE_INVOICE_TEST_DELIVERY.md` e `docs/SERVICE_INVOICE_ISSUANCE_SCOPE.md`. Série real será nova; proponente prepara/emite e pede correcções, validador fiscal aprova séries e regra temporal. Migração `2026_10_26_060000_create_test_invoice_issuance.php`, código/número inicial reais e oito concessões propostas permanecem sem aplicação e exigem confirmação específica. Não retirar `ServiceInvoicing::guard`, não criar documentos fictícios em desenvolvimento, não confundir pressupostos temporais sintéticos com pendências resolvidas do caso real. Preservar `9f48004`, B1 e aprovações existentes; sem recebimentos, lançamentos, comunicação externa ou publicação.
+5 -1
View File
@@ -1,5 +1,9 @@
# SGF TEMPO
**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.
Primeiro percurso funcional local, sobre a fundação existente e o roteiro v0.9 de 25 de Outubro de 2026. Laravel modular com React, independente do InvesteMoz. Versões e lockfiles preservados; sem módulos financeiros, integrações externas ou publicação.
**Base aceite:** incremento local de entrada, contexto e permissões aceite e concluído pelo utilizador em 25 de Outubro de 2026, com os limites de [IDENTITY_DELIVERY.md](docs/IDENTITY_DELIVERY.md). Preservar esta base. A autorização posterior de serviços está delimitada abaixo.
@@ -89,7 +93,7 @@ Não existe criação de tenants ou empresas pela interface, auto-registo, opç
## Testes e ferramentas
O percurso de **Facturas de teste**, navegador e PDF da mesma fotografia, está demonstrado apenas na instância isolada. A aplicação normal recusa emissão no servidor. Migração/concessões e série reais continuam pendentes de confirmações específicas; ver [entrega técnica, provas, percurso e limitações](docs/SERVICE_INVOICE_TEST_DELIVERY.md). Não executar scripts de fixtures na base normal ou interpretar os documentos de teste como facturas fiscais.
O percurso de **Facturas de teste**, navegador e PDF da mesma fotografia, está demonstrado apenas na instância isolada. A migração e as oito concessões iniciais foram especificamente autorizadas e aplicadas em desenvolvimento, com dados anteriores preservados. A aplicação normal continua a recusar emissão/configuração real no servidor; nenhuma série real ou documento foi criado. Actualizar a sessão para consultar o bloqueio em **Facturas de teste**. Ver [entrega técnica, aplicação verificada e limitações](docs/SERVICE_INVOICE_TEST_DELIVERY.md). Não executar fixtures na base normal ou interpretar documentos de teste como facturas fiscais.
O incremento de cálculo condicionado foi aceite; ver [entrega, verificações e limites](docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md). Na empresa/unidade autorizadas, consultar **Perfil fiscal do serviço** e **Rascunhos de facturação → Pré-visualizar rascunho**. A activação é uma operação local especificamente autorizada, sem via no navegador. O cálculo exige decisão temporal aprovada e compatível; quando há pendência, não determina exigibilidade ou período de apuramento. Sem emissão, recebimentos ou lançamentos.
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Services\Http;
use App\Http\Controllers\Controller;
use App\Modules\Services\ServiceReceivables;
use Illuminate\Http\Request;
final class ServiceReceivablesController extends Controller
{
private function response(array $d) { return response()->json($d)->header('Cache-Control','no-store, private'); }
public function index(Request $r,ServiceReceivables $s) { return $this->response($s->listing($r->user()->id)); }
public function propose(Request $r,ServiceReceivables $s) { return $this->response($s->propose($r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
public function decide(Request $r,int $id,ServiceReceivables $s) { return $this->response($s->decide($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
public function allocate(Request $r,int $id,ServiceReceivables $s) { return $this->response($s->allocate($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
}
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/** Isolated operational subledger. Never invokes fiscal, banking or accounting consumers. */
final class ServiceReceivables
{
public const POLICY='SGF-RECEIVABLES-SIM-v1';
public const NOTICE='TESTE — SEM VALIDADE FISCAL';
public const SIMULATION='SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO';
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
public static function guard(): void
{
abort_unless(ServiceCalculation::isolated(),403,'Recebimentos e reconciliação disponíveis exclusivamente em testes isolados.');
TestingDatabaseGuard::assertSafe();
}
private function scope(int $actor,string $right): array
{
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
abort_unless($actor===$this->context->userId() && $s['unit_id']!==null,403);
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach (['view',$right] as $r) $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_receivables.'.$r,$s['unit_id']);
return $s;
}
public static function money(mixed $value,bool $positive=true): string
{
$v=DraftValues::decimal($value,'amount');
abort_unless($v!==null,422,'Montante explícito necessário.');
$n=BigDecimal::of($v);
abort_if(($positive && $n->isLessThanOrEqualTo(0)) || $n->getScale()>2,422,'Use um montante positivo exactamente representável a duas casas; sem truncagem.');
return (string)$n->toScale(2,RoundingMode::UNNECESSARY);
}
private function validate(array $d,array $rules): array
{
abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo protegido ou não suportado.');
return Validator::make($d,$rules)->validate();
}
private function json(mixed $d): string { return json_encode($d,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE); }
private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
private function row(int $id,array $s,?string $kind=null): object
{
$q=DB::table('sr_records')->where($s)->where('id',$id);
if($kind) $q->where('kind',$kind);
return $q->first() ?? abort(403,'Identidade fora do âmbito ou tipo incorrecto.');
}
private function revision(int $ob): int { return DB::table('sr_effects')->where('obligation_id',$ob)->count(); }
private function command(int $actor,string $right,string $key,array $input,callable $write): array
{
self::guard(); $s=$this->scope($actor,$right);
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência obrigatória.');
ksort($input); $hash=hash('sha256',$this->json($input));
return DB::transaction(function() use($actor,$right,$key,$input,$hash,$s,$write) {
// Same global order as the database guards: company, then immutable records/effects.
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$this->scope($actor,$right);
$old=DB::table('sr_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$right,'command_key'=>$key])->first();
if($old) {
abort_unless(hash_equals($old->request_hash,$hash),409,'Chave já usada com conteúdo diferente.');
return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true];
}
$result=$write($s);
$cmd=DB::table('sr_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>$right,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($result),'created_at'=>now()]);
Audit::record('service_receivables.'.$right,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['command_id'=>$cmd,'record_id'=>$result['id'],'simulation_only'=>true]);
$event=DB::table('sr_events')->insertGetId(['command_id'=>$cmd,'type'=>$result['event'],'schema_version'=>1,'payload'=>$this->json($s+['policy'=>self::POLICY,'command_id'=>$cmd,'actor_id'=>$actor,'result'=>$result]),'created_at'=>now()]);
DB::table('sr_outbox')->insert(['event_id'=>$event,'state'=>'captured_simulation_only']);
return $result+['replayed'=>false];
},3);
}
private function record(array $s,int $actor,string $kind,object $inv,int $client,?int $ob,?int $receipt,string $amount,string $source,string $external,array $data): array
{
abort_if(DB::table('sr_records')->where($s)->where(['kind'=>$kind,'source'=>$source,'external_id'=>$external])->exists(),409,'Acontecimento já registado; consulte a identidade original.');
if($kind==='obligation') abort_if(DB::table('sr_records')->where('invoice_id',$inv->id)->where('kind','obligation')->exists(),409,'Documento já tem obrigação.');
$origin=['invoice_id'=>$inv->id,'snapshot_sha256'=>hash('sha256',$inv->snapshot),'series'=>$inv->series_code,'number'=>$inv->number,'issued_at'=>$inv->issued_at];
$id=DB::table('sr_records')->insertGetId($s+['invoice_id'=>$inv->id,'client_id'=>$client,'obligation_id'=>$ob,'receipt_id'=>$receipt,'kind'=>$kind,'currency'=>'MZN','amount'=>$amount,'source'=>$source,'external_id'=>$external,'data'=>$this->json(array_replace($data,['policy'=>self::POLICY,'origin'=>$origin])),'version'=>1,'actor_id'=>$actor,'created_at'=>now()]);
return ['id'=>$id,'version'=>1,'event'=>$kind.'.proposed'];
}
public function propose(int $actor,array $input,string $key): array
{
self::guard();
$d=$this->validate($input,['kind'=>'required|in:obligation,receipt,observation,correction','invoice_id'=>'required|integer|min:1','obligation_id'=>'nullable|integer|min:1','receipt_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','amount'=>'nullable|string','source'=>'required|string|min:3|max:80','external_id'=>'required|string|min:3|max:100','date'=>'nullable|date_format:Y-m-d','evidence'=>'nullable|string|min:3|max:1000','due_date'=>'nullable|date_format:Y-m-d','reason'=>'nullable|string|min:3|max:1000','action'=>'nullable|in:reverse_allocation,reverse_reconcile,reverse_receipt,reduction,due','target_effect_id'=>'nullable|integer|min:1','document_ref'=>'nullable|string|max:100','line'=>'nullable|in:1','revision'=>'required|integer|min:0']);
$kind=$d['kind'];
$right=$kind==='correction'?'correction_request':'prepare';
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
return $this->command($actor,$right,$key,$d,function($s)use($actor,$d,$kind) {
$inv=DB::table('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
abort_unless($inv,403,'Documento de origem não autorizado.');
$snapshot=json_decode($inv->snapshot,true,32,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['simulation_only']??false)===true && ($snapshot['notice']??'')===self::NOTICE && ($snapshot['document']['data']['currency']??'')==='MZN',422,'Origem sintética compatível necessária.');
$history=DB::table('a_history')->where($s)->where(['area'=>'billing_drafts','record_id'=>$inv->draft_id,'version'=>$snapshot['document']['draft_version']])->value('snapshot');
abort_unless($history,409,'Versão histórica da origem necessária.');
$client=(int)json_decode($history,true,32,JSON_THROW_ON_ERROR)['client_id'];
$ob=null; $receipt=null; $amount='0.00';
if($kind==='obligation') {
abort_if(!empty($d['obligation_id']) || !empty($d['receipt_id']) || $d['revision']!==0,422);
$amount=self::money($snapshot['calculation']['total_document_proposed']);
abort_unless(!isset($d['amount']) || $d['amount']===$amount,422,'Montante deve coincidir com a fotografia.');
} else {
$ob=$this->row((int)($d['obligation_id']??0),$s,'obligation');
abort_unless($ob->invoice_id===$inv->id,403);
abort_unless($d['revision']===$this->revision($ob->id),409,'Revisão desactualizada.');
abort_unless(DB::table('sr_effects')->where(['record_id'=>$ob->id,'kind'=>'open'])->exists(),409,'Obrigação ainda não aberta.');
if(!empty($d['receipt_id'])) { $receipt=$this->row($d['receipt_id'],$s,'receipt'); abort_unless($receipt->obligation_id===$ob->id,403); }
}
if(in_array($kind,['receipt','observation'])) {
abort_unless(isset($d['amount'],$d['date'],$d['evidence']),422,'Montante, data e evidência sintética são obrigatórios.');
abort_if($d['date']>now(ServiceInvoicing::ZONE)->format('Y-m-d'),422,'Data futura não comprova recebimento ou observação.');
$amount=$d['amount'];
abort_if(($kind==='receipt' && $receipt) || ($kind==='observation' && !$receipt),422);
}
if($kind==='correction') {
abort_unless(isset($d['action'],$d['reason']),422,'Acção e motivo necessários.');
if($d['action']==='reduction') {
abort_unless(isset($d['amount'],$d['document_ref'],$d['line']) && str_starts_with($d['document_ref'],'TEST-REDUCTION-'),422,'Documento redutor sintético e linha necessários.');
$amount=$d['amount'];
}
if(in_array($d['action'],['reverse_allocation','reverse_reconcile'])) {
$effect=DB::table('sr_effects')->where('obligation_id',$ob->id)->where('id',$d['target_effect_id']??0)->first();
abort_unless($effect && $effect->kind===($d['action']==='reverse_allocation'?'allocate':'reconcile'),422,'Alvo de reversão incompatível.');
$receipt=$this->row($effect->receipt_id,$s,'receipt'); $amount=$effect->amount;
}
if($d['action']==='reverse_receipt') { abort_unless($receipt,422); $amount=$receipt->amount; }
}
return $this->record($s,$actor,$kind,$inv,$client,$ob?->id,$receipt?->id,$amount,$d['source'],$d['external_id'],$d);
});
}
public function decide(int $id,int $actor,array $input,string $key): array
{
self::guard(); $s=$this->scope($actor,'view'); $r=$this->row($id,$s);
$right=$r->kind==='correction'?'correction_approve':($r->kind==='observation'?'reconcile':'confirm');
$d=$this->validate($input,['version'=>'required|integer|in:1','revision'=>'required|integer|min:0','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
return $this->command($actor,$right,$key,['record_id'=>$id]+$d,function($s)use($id,$actor,$d) {
$r=$this->row($id,$s); $rd=$this->data($r); $ob=$r->kind==='obligation'?$r->id:$r->obligation_id;
abort_if($r->actor_id===$actor,403,'Confirmação/aprovação exige identidade distinta.');
abort_unless($d['revision']===$this->revision($ob),409,'Estado alterado; reveja a decisão.');
abort_if(DB::table('sr_effects')->where('record_id',$id)->where('kind','<>','allocate')->exists(),409,'Versão já tem decisão.');
$kind='refused'; $amount='0.00'; $receipt=null; $target=null;
if($d['decision']==='approved') {
if($r->kind==='obligation') { $kind='open'; $amount=$r->amount; }
if($r->kind==='receipt') { $kind='receipt_confirm'; $amount=$r->amount; }
if($r->kind==='observation') {
$receipt=$r->receipt_id; $rc=$this->row($receipt,$s,'receipt');
$diff=BigDecimal::of($rc->amount)->minus($r->amount)->abs();
$kind=$diff->isZero()?'reconcile':'divergence'; $amount=$diff->isZero()?$rc->amount:(string)$diff->toScale(2);
}
if($r->kind==='correction') {
abort_unless(($rd['revision']??-1)===$d['revision'],409,'Proposta ligada a revisão anterior; prepare nova proposta.');
$kind=$rd['action']; $amount=$r->amount; $receipt=$r->receipt_id; $target=$rd['target_effect_id']??null;
if($kind==='reduction' || $kind==='due') $receipt=null;
}
} elseif($r->kind==='observation') abort(422,'A conferência regista correspondência ou divergência; não é aprovação de recibo.');
return $this->effect($id,$ob,$receipt,$kind,$amount,$target,$d['revision'],$actor,$d+['due_date'=>$rd['due_date']??null]);
});
}
private function effect(int $record,int $ob,?int $receipt,string $kind,string $amount,?int $target,int $revision,int $actor,array $data): array
{
$state=$this->state($ob);
if($kind==='allocate') {
$r=$state['receipts'][$receipt]??abort(409,'Recebimento indisponível.');
abort_unless($r['state']==='confirmed' && BigDecimal::of($amount)->isLessThanOrEqualTo($r['available']) && BigDecimal::of($amount)->isLessThanOrEqualTo($state['balance']),409,'Sobreafectação recusada integralmente.');
}
if($kind==='reduction') abort_if(BigDecimal::of($amount)->isGreaterThan($state['balance']),409,'Liberte afectação antes da redução.');
if($kind==='reverse_receipt') {
$r=$state['receipts'][$receipt]??abort(409);
abort_unless($r['state']==='confirmed' && $r['allocated']==='0.00' && !$r['reconciled'],409,'Reverta afectações e reconciliação primeiro.');
}
if(in_array($kind,['reconcile','divergence'])) {
$r=$state['receipts'][$receipt]??abort(409);
abort_unless($r['state']==='confirmed' && !$r['reconciled'],409,'Recebimento indisponível ou já conferido.');
}
if($target) abort_if(DB::table('sr_effects')->where('target_effect_id',$target)->exists(),409,'Efeito já revertido.');
$id=DB::table('sr_effects')->insertGetId(['record_id'=>$record,'obligation_id'=>$ob,'receipt_id'=>$receipt,'kind'=>$kind,'amount'=>$amount,'target_effect_id'=>$target,'revision'=>$revision,'data'=>$this->json($data),'actor_id'=>$actor,'created_at'=>now()]);
return ['id'=>$id,'record_id'=>$record,'obligation_id'=>$ob,'event'=>$kind.'.simulation','revision'=>$revision+1];
}
public function allocate(int $id,int $actor,array $input,string $key): array
{
self::guard(); $d=$this->validate($input,['amount'=>'required|string','revision'=>'required|integer|min:0','version'=>'required|integer|in:1']);
$d['amount']=self::money($d['amount']);
return $this->command($actor,'allocate',$key,['receipt_id'=>$id]+$d,function($s)use($id,$actor,$d) {
$r=$this->row($id,$s,'receipt'); abort_unless($d['revision']===$this->revision($r->obligation_id),409,'Revisão desactualizada.');
return $this->effect($id,$r->obligation_id,$id,'allocate',$d['amount'],null,$d['revision'],$actor,[]);
});
}
private function state(int $ob,?string $cut=null): array
{
$r=DB::table('sr_records')->find($ob); $rd=$this->data($r);
$effects=DB::table('sr_effects')->where('obligation_id',$ob)->orderBy('id')->get();
$receipts=[]; $balance=BigDecimal::of($r->amount); $reduced=BigDecimal::zero(); $allocated=BigDecimal::zero(); $due=$rd['due_date']??null; $opened=false; $refused=false;
foreach(DB::table('sr_records')->where('obligation_id',$ob)->where('kind','receipt')->get() as $rc) $receipts[$rc->id]=['id'=>$rc->id,'amount'=>$rc->amount,'state'=>'proposed','allocated'=>'0.00','available'=>'0.00','reconciled'=>false];
foreach($effects as $e) {
if($e->kind==='open') $opened=true;
if($e->kind==='refused' && $e->record_id===$ob) $refused=true;
if($e->kind==='due') $due=$this->data($e)['due_date'];
if($e->kind==='receipt_confirm') { $receipts[$e->record_id]['state']='confirmed'; $receipts[$e->record_id]['available']=$e->amount; }
if($e->kind==='refused' && isset($receipts[$e->record_id])) $receipts[$e->record_id]['state']='refused';
if(in_array($e->kind,['allocate','reverse_allocation'])) {
$x=BigDecimal::of($e->amount)->multipliedBy($e->kind==='allocate'?1:-1); $allocated=$allocated->plus($x);
$rc=&$receipts[$e->receipt_id]; $rc['allocated']=(string)BigDecimal::of($rc['allocated'])->plus($x)->toScale(2); $rc['available']=(string)BigDecimal::of($rc['available'])->minus($x)->toScale(2); unset($rc);
}
if($e->kind==='reduction') $reduced=$reduced->plus($e->amount);
if($e->kind==='reconcile') $receipts[$e->receipt_id]['reconciled']=true;
if($e->kind==='reverse_reconcile') $receipts[$e->receipt_id]['reconciled']=false;
if($e->kind==='reverse_receipt') { $receipts[$e->receipt_id]['state']='reversed'; $receipts[$e->receipt_id]['available']='0.00'; }
}
$balance=$balance->minus($reduced)->minus($allocated); $received=BigDecimal::zero(); $available=BigDecimal::zero();
foreach($receipts as $rc) if($rc['state']==='confirmed') { $received=$received->plus($rc['amount']); $available=$available->plus($rc['available']); }
$cut??=now(ServiceInvoicing::ZONE)->format('Y-m-d');
return ['id'=>$ob,'original'=>$r->amount,'reduced'=>(string)$reduced->toScale(2),'received'=>(string)$received->toScale(2),'allocated'=>(string)$allocated->toScale(2),'available'=>(string)$available->toScale(2),'balance'=>(string)$balance->toScale(2),'opened'=>$opened,'state'=>!$opened?($refused?'refused':'proposed'):($balance->isZero()?'settled':($allocated->isPositive()?'partial':'open')),'due_date'=>$due,'due_state'=>!$opened?'not_open':($balance->isZero()?'settled':($due===null?'unknown':($cut>$due?'overdue':'not_due'))),'cut_date'=>$cut,'revision'=>$effects->count(),'receipts'=>$receipts,'effects'=>$effects->all()];
}
public function listing(int $actor): array
{
$s=$this->scope($actor,'view');
if(!ServiceCalculation::isolated()) return ['blocked'=>true,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'obligations'=>[],'records'=>[],'documents'=>[]];
self::guard();
return DB::transaction(function()use($s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$records=DB::table('sr_records')->where($s)->orderBy('id')->get();
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>DB::table('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
});
}
/** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
public function deliver(int $event): bool
{
self::guard();
return DB::transaction(function()use($event) {
$e=DB::table('sr_events')->where('id',$event)->lockForUpdate()->firstOrFail();
if(DB::table('sr_deliveries')->where('event_id',$event)->exists()) return false;
$cmd=DB::table('sr_commands')->find($e->command_id); $result=json_decode($cmd->result,true,32,JSON_THROW_ON_ERROR);
$ob=$result['obligation_id']??null;
if($ob) { $state=$this->state($ob); abort_if(BigDecimal::of($state['balance'])->isNegative(),409,'Integridade do saldo falhou.'); }
DB::table('sr_deliveries')->insert(['event_id'=>$event,'consumer'=>'simulation_integrity_v1','created_at'=>now()]);
return true;
});
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Workshop\Http;
use App\Http\Controllers\Controller;
use App\Modules\Workshop\WorkshopWorkflow;
use Illuminate\Http\Request;
final class WorkshopController extends Controller
{
private function response(array $data) { return response()->json($data)->header('Cache-Control','no-store, private'); }
public function index(Request $r,WorkshopWorkflow $w) { return $this->response($w->listing($r->user()->id)); }
public function open(Request $r,WorkshopWorkflow $w) { return $this->response($w->open($r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
public function record(Request $r,int $id,WorkshopWorkflow $w) { return $this->response($w->record($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
public function verify(Request $r,int $id,WorkshopWorkflow $w) { return $this->response($w->verify($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
}
+239
View File
@@ -0,0 +1,239 @@
<?php
namespace App\Modules\Workshop;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Services\DraftValues;
use App\Modules\Services\ServiceCalculation;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
final class WorkshopWorkflow
{
public const POLICY='WORKSHOP-RESOURCE-RECORD-v1';
public const NOTICE='TESTE — SEM UTILIZAÇÃO OPERACIONAL';
public const ZONE='Africa/Johannesburg';
public function __construct(private CompanyContext $context,private Permissions $permissions) {}
public static function guard(): void {
abort_unless(ServiceCalculation::isolated(),403,'Oficina disponível apenas na instância isolada de testes.');
TestingDatabaseGuard::assertSafe();
}
private function scope(int $actor,string $right): array {
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
abort_unless($s['unit_id']!==null && $actor===$this->context->userId(),403);
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach(['view',$right] as $r) $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$r,$s['unit_id']);
return $s;
}
private function has(int $actor,string $right,array $s): bool { return $this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']); }
private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
private function json(mixed $value): string { return json_encode($value,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE); }
private function exact(array $data,array $rules): array {
abort_if(array_diff(array_keys($data),array_keys($rules)),422,'Campo protegido ou fora do recorte.');
return Validator::make($data,$rules)->validate();
}
private function canonical(mixed $v): mixed {
if(!is_array($v)) return $v;
if(!array_is_list($v)) ksort($v);
return array_map(fn($x)=>$this->canonical($x),$v);
}
private function revision(int $id): int { return (int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$id])->v; }
private function order(int $id,int $actor,array $s): object {
$o=DB::table('wo_orders')->where($s)->where('id',$id)->first();
abort_unless($o,403,'Ordem fora do âmbito autorizado.');
if(!$this->has($actor,'open',$s) && !$this->has($actor,'assign',$s) && !$this->has($actor,'authorize',$s) && !$this->has($actor,'verify',$s)) {
abort_unless((int)DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'assignment'])->orderByDesc('id')->value('technician_id')===$actor,403,'Ordem não atribuída ao técnico.');
}
return $o;
}
private function entry(int $id,int $order,?string $kind=null): object {
$q=DB::table('wo_entries')->where(['order_id'=>$order,'id'=>$id]); if($kind) $q->where('kind',$kind);
return $q->first()??abort(403,'Apontamento/origem fora da ordem.');
}
private function current(object $e): bool { return !DB::table('wo_entries')->where('previous_id',$e->id)->exists(); }
private function command(int $actor,string $right,string $key,array $input,?int $order,callable $write): array {
self::guard(); $s=$this->scope($actor,$right);
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência obrigatória.');
$hash=hash('sha256',$this->json($this->canonical($input)));
return DB::transaction(function()use($actor,$right,$key,$hash,$s,$write,$order) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail(); $this->scope($actor,$right);
if($order) $this->order($order,$actor,$s);
$old=DB::table('wo_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$right,'command_key'=>$key])->first();
if($old) {
$this->order($old->order_id,$actor,$s);
abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com conteúdo diferente.');
return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true];
}
$out=$write($s);
$cmd=DB::table('wo_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>$right,'command_key'=>$key,'request_hash'=>$hash,'order_id'=>$out['order_id'],'result'=>$this->json($out),'created_at'=>now()]);
Audit::record('workshop.'.$out['event'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']??1]);
$event=DB::table('wo_events')->insertGetId(['command_id'=>$cmd,'type'=>$out['event'],'schema_version'=>1,'payload'=>$this->json($s+['policy'=>self::POLICY,'actor_id'=>$actor,'command_id'=>$cmd,'result'=>$out]),'created_at'=>now()]);
DB::table('wo_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_cost_stock_or_billing']);
return $out+['replayed'=>false];
},3);
}
public function open(int $actor,array $input,string $key): array {
self::guard();
$d=$this->exact($input,['reference'=>'required|string|min:3|max:100','client_id'=>'required|integer|min:1','equipment_id'=>'nullable|integer|min:1','equipment_identifier'=>'required_without:equipment_id|string|max:100','equipment_description'=>'required_without:equipment_id|string|max:300','serial'=>'nullable|string|max:100','request'=>'required|string|min:3|max:2000','location'=>'required|string|max:300','evidence'=>'required|string|min:3|max:500']);
return $this->command($actor,'open',$key,$d,null,function($s)use($d,$actor) {
abort_unless(DB::table('a_clients')->where(['id'=>$d['client_id'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente de outra empresa.');
abort_if(DB::table('wo_orders')->where(['company_id'=>$s['company_id'],'reference'=>$d['reference']])->exists(),409,'Referência de ordem já existente.');
$equipment=$d['equipment_id']??null;
if($equipment) abort_unless(DB::table('wo_equipment')->where(['id'=>$equipment,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id']])->exists(),403,'Equipamento incompatível com o cliente.');
else {
abort_if(DB::table('wo_equipment')->where(['company_id'=>$s['company_id'],'identifier'=>$d['equipment_identifier']])->exists(),409,'Equipamento já identificado; reutilize o ID existente.');
$equipment=DB::table('wo_equipment')->insertGetId(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id'],'identifier'=>$d['equipment_identifier'],'description'=>$d['equipment_description'],'serial'=>$d['serial']??null,'actor_id'=>$actor,'created_at'=>now()]);
}
$id=DB::table('wo_orders')->insertGetId($s+['client_id'=>$d['client_id'],'equipment_id'=>$equipment,'reference'=>$d['reference'],'request'=>$d['request'],'location'=>$d['location'],'evidence'=>$d['evidence'],'actor_id'=>$actor,'created_at'=>now()]);
return ['id'=>$id,'order_id'=>$id,'equipment_id'=>$equipment,'event'=>'order.opened','version'=>1];
});
}
private function timestamp(string $value): string {
abort_unless(preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(Z|[+-]\d{2}:\d{2})$/D',$value),422,'Use data/hora completa com zona explícita.');
try { return CarbonImmutable::parse($value)->utc()->format('Y-m-d\TH:i:sP'); }
catch(\Throwable) { abort(422,'Data/hora inválida.'); }
}
public function record(int $id,int $actor,array $input,string $key): array {
self::guard();
$d=$this->exact($input,['kind'=>'required|in:assignment,task,authorization,start,time,material','origin'=>'required|string|min:3|max:100','revision'=>'required|integer|min:0','previous_id'=>'nullable|integer|min:1','correction_reason'=>'nullable|string|min:3|max:1000','version'=>'required|integer|min:1',
'technician_id'=>'nullable|integer|min:1','task_id'=>'nullable|integer|min:1','authorization_id'=>'nullable|integer|min:1','start_id'=>'nullable|integer|min:1',
'description'=>'nullable|string|min:3|max:2000','service_id'=>'nullable|integer|min:1','evidence'=>'nullable|string|min:3|max:1000',
'authorized_by'=>'nullable|string|min:3|max:255','capacity'=>'nullable|string|min:3|max:255','authorized_at'=>'nullable|date','channel'=>'nullable|string|min:3|max:100','scope'=>'nullable|string|min:3|max:2000',
'starts_at'=>'nullable|date','ends_at'=>'nullable|date','pauses'=>'nullable|array|max:100','pauses.*'=>'array:start,end','pauses.*.start'=>'required|date','pauses.*.end'=>'required|date','mode'=>'nullable|in:reported,started','time_type'=>'nullable|in:work,travel',
'quantity'=>'nullable|string','resource'=>'nullable|string|min:2|max:100','unit'=>'nullable|in:UN,KG,M,L','owner'=>'nullable|in:company,client','event'=>'nullable|in:required,delivered,consumed,returned']);
foreach(['starts_at','ends_at','authorized_at'] as $field) if(isset($d[$field])) $d[$field]=$this->timestamp($d[$field]);
if(isset($d['quantity'])) $d['quantity']=DraftValues::decimal($d['quantity'],'quantity',true);
$d['pauses']=array_map(fn($p)=>['start'=>$this->timestamp($p['start']),'end'=>$this->timestamp($p['end'])],$d['pauses']??[]);
$right=match($d['kind']) {'assignment'=>'assign','authorization'=>'authorize',default=>'record'};
return $this->command($actor,$right,$key,['order_id'=>$id]+$d,$id,function($s)use($id,$actor,$d) {
abort_unless((int)$d['revision']===$this->revision($id),409,'Revisão da ordem desactualizada.');
$kind=$d['kind']; $prev=null;
if(!empty($d['previous_id'])) {
$prev=$this->entry($d['previous_id'],$id,$kind);
abort_unless($this->current($prev) && $prev->origin===$d['origin'] && (int)$d['version']===$prev->version+1 && !empty($d['correction_reason']),409,'Nova versão exige origem, motivo e versão anterior exactos.');
if(in_array($kind,['time','material','task'])) abort_unless($prev->actor_id===$actor,403,'Não pode corrigir o apontamento de outro autor.');
} else {
abort_unless((int)$d['version']===1,422);
abort_if(DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>$kind,'origin'=>$d['origin']])->exists(),409,'Origem já registada; corrija por versão explícita.');
}
$assigned=(int)DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'assignment'])->orderByDesc('id')->value('technician_id');
$tech=null; $task=null; $auth=null; $start=null; $minutes=null; $quantity=null; $outside=false; $starts=$d['starts_at']??null; $ends=$d['ends_at']??null;
if($kind==='assignment') {
$tech=$d['technician_id']??0;
foreach(['view','record'] as $r) $this->permissions->require($tech,$s['tenant_id'],$s['company_id'],'workshop.'.$r,$s['unit_id']);
}
if(in_array($kind,['task','start','time','material'])) { abort_unless($assigned===$actor,403,'Apenas o técnico atribuído regista.'); $tech=$actor; }
if($kind==='task') abort_unless(!empty($d['description']),422,'Descrição da tarefa necessária.');
if(in_array($kind,['authorization','start','time','material'])) {
$task=$this->entry((int)($d['task_id']??0),$id,'task');
if(in_array($kind,['authorization','start'])) abort_unless($this->current($task),409,'Tarefa tem nova versão; reveja âmbito.');
}
if($kind==='authorization') {
foreach(['authorized_by','capacity','authorized_at','channel','scope','evidence'] as $field) abort_unless(!empty($d[$field]),422,'Autorização: pessoa, qualidade, data, canal, âmbito e comprovativo necessários.');
abort_if(CarbonImmutable::parse($d['authorized_at'])->isAfter(now()),422,'Autorização futura não é facto.');
// Revisions of one authorization cannot silently move to another task or authorize it retroactively.
if($prev) abort_unless($prev->task_id===$task->id || !$this->current($this->entry($prev->task_id,$id,'task')),409,'Alteração de tarefa exige revisão explícita da origem.');
}
if(in_array($kind,['start','time','material'])) {
abort_unless(!empty($d['evidence']),422,'Evidência da ocorrência necessária.');
if(!empty($d['authorization_id'])) $auth=$this->entry($d['authorization_id'],$id,'authorization');
if($auth) abort_unless($auth->task_id===$task->id,409,'Autorização não cobre esta versão da tarefa.');
if($kind==='start') { $starts=now()->toIso8601String(); $ends=null; }
$outside=!$auth || !$this->current($auth) || !$this->current($task) || CarbonImmutable::parse($auth->created_at)->isAfter(CarbonImmutable::parse($starts??now())) || CarbonImmutable::parse($this->data($auth)['authorized_at'])->isAfter(CarbonImmutable::parse($starts??now()));
if($kind==='start') {
abort_if($outside,409,'Início bloqueado: autorização da versão da tarefa necessária. Pode relatar uma ocorrência já acontecida para revisão.');
abort_if(DB::table('wo_entries as e')->join('wo_orders as o','o.id','=','e.order_id')->where('o.company_id',$s['company_id'])->where(['e.kind'=>'start','e.technician_id'=>$actor])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as done')->whereColumn('done.start_id','e.id'))->exists(),409,'Já existe trabalho iniciado sem apontamento final.');
}
if($kind==='time') {
abort_unless($starts && $ends && !empty($d['time_type']) && !empty($d['mode']),422,'Intervalo, tipo e modo de relato necessários.');
$from=CarbonImmutable::parse($starts); $to=CarbonImmutable::parse($ends);
abort_unless($to->isAfter($from) && !$to->isAfter(now()),422,'Intervalo inválido ou ocorrência futura.');
$seconds=$to->getTimestamp()-$from->getTimestamp(); $last=$from;
foreach($d['pauses'] as $pause) {
$a=CarbonImmutable::parse($pause['start']); $b=CarbonImmutable::parse($pause['end']);
abort_unless(!$a->isBefore($last) && $b->isAfter($a) && !$b->isAfter($to),422,'Pausas inválidas/sobrepostas.');
$seconds-=$b->getTimestamp()-$a->getTimestamp(); $last=$b;
}
abort_unless($seconds>0 && $seconds%60===0,422,'Tempo efectivo deve ser exactamente representável em minutos; sem truncagem.'); $minutes=intdiv($seconds,60);
if(!empty($d['start_id'])) {
$start=$this->entry($d['start_id'],$id,'start');
abort_unless($start->technician_id===$actor && $start->task_id===$task->id && $start->authorization_id===($auth?->id) && CarbonImmutable::parse($start->starts_at)->eq($from),409,'Início incompatível.');
abort_if(DB::table('wo_entries')->where('start_id',$start->id)->exists(),409,'Início já concluído.');
} else abort_unless($d['mode']==='reported',422,'Seleccione relato de ocorrência ou indique início existente.');
// A correction cannot silently turn an already reported exception into authorized work.
if($prev && $prev->outside_authority) $outside=true;
$overlap=DB::table('wo_entries as e')->join('wo_orders as o','o.id','=','e.order_id')->where('o.company_id',$s['company_id'])->where(['e.kind'=>'time','e.technician_id'=>$actor])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as n')->whereColumn('n.previous_id','e.id'))->when($prev,fn($q)=>$q->where('e.id','<>',$prev->id))->where('e.starts_at','<',$ends)->where('e.ends_at','>',$starts)->exists();
abort_if($overlap,409,'Intervalo sobreposto do mesmo técnico; não será duplicado.');
}
if($kind==='material') {
foreach(['quantity','resource','unit','owner','event','starts_at'] as $f) abort_unless(isset($d[$f]),422,'Material: recurso, unidade, propriedade, tipo, quantidade e data necessários.');
abort_if(CarbonImmutable::parse($starts)->isAfter(now()),422,'Ocorrência futura não é material observado.');
$quantity=$d['quantity'];
foreach(DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'material'])->get() as $other) {
$od=$this->data($other);
if($od['resource']===$d['resource']) abort_unless($od['unit']===$d['unit'] && $od['owner']===$d['owner'],409,'A referência material conserva unidade e propriedade.');
}
if($prev) { $pd=$this->data($prev); foreach(['resource','unit','owner','event'] as $field) abort_unless($pd[$field]===$d[$field],409,'A correcção não pode transferir a origem material para outro acontecimento.'); }
if($d['event']!=='consumed') $outside=false;
if($prev && $prev->outside_authority) $outside=true;
}
}
$data=$d+['policy'=>self::POLICY];
$entry=DB::table('wo_entries')->insertGetId(['order_id'=>$id,'kind'=>$kind,'origin'=>$d['origin'],'version'=>$d['version'],'previous_id'=>$prev?->id,'task_id'=>$task?->id,'authorization_id'=>$auth?->id,'start_id'=>$start?->id,'technician_id'=>$tech,'starts_at'=>$starts,'ends_at'=>$ends,'minutes'=>$minutes,'quantity'=>$quantity,'outside_authority'=>$outside,'revision'=>$d['revision'],'data'=>$this->json($data),'actor_id'=>$actor,'created_at'=>now()]);
$actual=DB::table('wo_entries')->find($entry);
return ['id'=>$entry,'order_id'=>$id,'version'=>(int)$d['version'],'event'=>$kind.'.recorded','outside_authority'=>(bool)$actual->outside_authority,'revision'=>$this->revision($id)];
});
}
public function verify(int $id,int $actor,array $input,string $key): array {
self::guard(); $s=$this->scope($actor,'verify'); $entry=DB::table('wo_entries')->find($id); abort_unless($entry,403); $this->order($entry->order_id,$actor,$s);
$d=$this->exact($input,['version'=>'required|integer|min:1','revision'=>'required|integer|min:0','decision'=>'required|in:verified,refused,acknowledged','reason'=>'required|string|min:3|max:1000']);
return $this->command($actor,'verify',$key,['entry_id'=>$id]+$d,$entry->order_id,function($s)use($entry,$actor,$d) {
$e=$this->entry($entry->id,$entry->order_id);
abort_unless(in_array($e->kind,['task','time','material']),422,'Este registo não é apontamento verificável.');
abort_if($e->actor_id===$actor,403,'Verificador deve ser distinto do autor.');
abort_unless($this->current($e) && (int)$d['version']===$e->version && (int)$d['revision']===$this->revision($e->order_id),409,'Versão/revisão desactualizada.');
abort_if(DB::table('wo_decisions')->where('entry_id',$e->id)->exists(),409,'Versão já decidida.');
abort_if($e->outside_authority && $d['decision']==='verified',409,'Fora do âmbito: pode reconhecer a ocorrência para auditoria ou recusar, sem autorizar retroactivamente.');
abort_if(!$e->outside_authority && $d['decision']==='acknowledged',422,'Reconhecimento de excepção apenas para ocorrência fora do âmbito.');
$decision=DB::table('wo_decisions')->insertGetId(['entry_id'=>$e->id,'entry_version'=>$d['version'],'revision'=>$d['revision'],'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);
return ['id'=>$decision,'entry_id'=>$e->id,'order_id'=>$e->order_id,'version'=>$e->version,'event'=>'entry.'.$d['decision'],'revision'=>$this->revision($e->order_id)];
});
}
public function listing(int $actor): array {
$s=$this->scope($actor,'view');
if(!ServiceCalculation::isolated()) return ['blocked'=>true,'notice'=>self::NOTICE,'orders'=>[]];
self::guard();
return DB::transaction(function()use($s,$actor) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$orders=[];
foreach(DB::table('wo_orders')->where($s)->orderBy('id')->get() as $o) {
try {$this->order($o->id,$actor,$s);} catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface) {continue;}
$entries=DB::table('wo_entries')->where('order_id',$o->id)->orderBy('id')->get();
foreach($entries as $e) {
$e->data=$this->data($e); $e->current=$this->current($e);
$e->decision=DB::table('wo_decisions')->where('entry_id',$e->id)->first();
}
$current=$entries->where('current',true); $points=$current->whereIn('kind',['task','time','material']);
$exception=$points->contains(fn($e)=>$e->outside_authority);
$pending=$points->contains(fn($e)=>!$e->decision || $e->decision->decision!=='verified');
$state=$points->isEmpty()?'open':($exception?'exception_review':($pending?'in_review':'records_verified'));
$materials=[];
foreach($current->where('kind','material') as $e) {
$k=$e->data['resource'].'|'.$e->data['owner'].'|'.$e->data['unit'];
$materials[$k]??=['resource'=>$e->data['resource'],'unit'=>$e->data['unit'],'owner'=>$e->data['owner'],'required'=>'0','delivered'=>'0','consumed'=>'0','returned'=>'0','verified_consumed'=>'0'];
$event=$e->data['event']; $materials[$k][$event]=(string)\Brick\Math\BigDecimal::of($materials[$k][$event])->plus($e->quantity);
if($event==='consumed' && $e->decision?->decision==='verified') $materials[$k]['verified_consumed']=(string)\Brick\Math\BigDecimal::of($materials[$k]['verified_consumed'])->plus($e->quantity);
}
foreach($materials as &$m) { $m['unexplained']=(string)\Brick\Math\BigDecimal::of($m['delivered'])->minus($m['consumed'])->minus($m['returned']); $m['status']=$m['unexplained']==='0'?'balanced_observations':'pending_physical_explanation'; } unset($m);
$orders[]=(array)$o+['equipment'=>DB::table('wo_equipment')->find($o->equipment_id),'revision'=>$this->revision($o->id),'assigned_to'=>$entries->where('kind','assignment')->last()?->technician_id,'state'=>$state,'entries'=>$entries->all(),'minutes_reported'=>$current->where('kind','time')->sum('minutes'),'minutes_verified'=>$current->where('kind','time')->filter(fn($e)=>$e->decision?->decision==='verified')->sum('minutes'),'materials'=>array_values($materials)];
}
$techs=$this->has($actor,'assign',$s)?DB::table('permission_grants as g')->join('users as u','u.id','=','g.user_id')->where(['g.company_id'=>$s['company_id'],'g.unit_id'=>$s['unit_id'],'g.permission'=>'workshop.record'])->get(['u.id','u.name'])->all():[];
$clients=DB::table('a_clients')->where(['company_id'=>$s['company_id'],'tenant_id'=>$s['tenant_id']]);
if(!$this->has($actor,'open',$s)) $clients->whereIn('id',array_column($orders,'client_id'));
return ['blocked'=>false,'notice'=>self::NOTICE,'policy'=>self::POLICY,'orders'=>$orders,'clients'=>$clients->get(['id','name'])->all(),'technicians'=>$techs];
});
}
}
@@ -0,0 +1,18 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
$old=substr(DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d,6);
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($old) OR (permission IN ('service_receivables.view','service_receivables.prepare','service_receivables.confirm','service_receivables.allocate','service_receivables.reconcile','service_receivables.correction_request','service_receivables.correction_approve') AND unit_id IS NOT NULL))");
DB::unprepared(file_get_contents(__DIR__.'/../sql/receivables.sql'));
foreach (['sr_records','sr_effects','sr_commands','sr_events','sr_outbox','sr_deliveries'] as $table) {
DB::statement("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
DB::statement("CREATE TRIGGER {$table}_test_only BEFORE INSERT ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_sr_test_insert()");
}
}
public function down(): void { throw new RuntimeException('Immutable history requires a reviewed preservation procedure.'); }
};
@@ -0,0 +1,18 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
$old=substr(DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d,6);
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($old) OR (permission IN ('workshop.view','workshop.open','workshop.assign','workshop.authorize','workshop.record','workshop.verify') AND unit_id IS NOT NULL))");
DB::unprepared(file_get_contents(__DIR__.'/../sql/workshop.sql'));
foreach(['wo_equipment','wo_orders','wo_entries','wo_decisions','wo_commands','wo_events','wo_outbox'] as $table) {
DB::statement("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
DB::statement("CREATE TRIGGER {$table}_test_only BEFORE INSERT ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_wo_test_only()");
}
}
public function down(): void { throw new RuntimeException('Workshop history requires a reviewed preservation procedure.'); }
};
+136
View File
@@ -0,0 +1,136 @@
CREATE TABLE sr_records (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, unit_id bigint NOT NULL,
invoice_id bigint NOT NULL REFERENCES si_invoices(id), client_id bigint NOT NULL REFERENCES a_clients(id),
obligation_id bigint REFERENCES sr_records(id), receipt_id bigint REFERENCES sr_records(id),
kind text NOT NULL CHECK(kind IN ('obligation','receipt','observation','correction')),
currency text NOT NULL CHECK(currency='MZN'), amount numeric NOT NULL CHECK(amount>=0 AND amount<10000000000000000 AND amount=trunc(amount,2)),
source text NOT NULL, external_id text NOT NULL, data jsonb NOT NULL,
version integer NOT NULL DEFAULT 1 CHECK(version=1), actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL,
UNIQUE(company_id,unit_id,kind,source,external_id),
FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE UNIQUE INDEX sr_one_obligation ON sr_records(invoice_id) WHERE kind='obligation';
CREATE UNIQUE INDEX sr_reduction_document ON sr_records(company_id,unit_id,(data->>'document_ref')) WHERE kind='correction' AND data->>'action'='reduction';
CREATE TABLE sr_effects (
id bigserial PRIMARY KEY, record_id bigint NOT NULL REFERENCES sr_records(id),
obligation_id bigint NOT NULL REFERENCES sr_records(id), receipt_id bigint REFERENCES sr_records(id),
kind text NOT NULL CHECK(kind IN ('open','receipt_confirm','refused','allocate','reconcile','divergence','reverse_allocation','reverse_reconcile','reverse_receipt','reduction','due')),
amount numeric NOT NULL CHECK(amount>=0 AND amount<10000000000000000 AND amount=trunc(amount,2)), target_effect_id bigint REFERENCES sr_effects(id),
revision integer NOT NULL CHECK(revision>=0), data jsonb NOT NULL,
actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX sr_terminal_decision ON sr_effects(record_id) WHERE kind NOT IN ('allocate','divergence');
CREATE UNIQUE INDEX sr_reverse_once ON sr_effects(target_effect_id) WHERE target_effect_id IS NOT NULL;
CREATE UNIQUE INDEX sr_divergence_once ON sr_effects(record_id) WHERE kind='divergence';
CREATE INDEX sr_obligation_effects ON sr_effects(obligation_id,id);
CREATE TABLE sr_commands (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, unit_id bigint NOT NULL,
actor_id bigint NOT NULL REFERENCES users(id), action text NOT NULL, command_key varchar(100) NOT NULL,
request_hash varchar(64) NOT NULL, result jsonb NOT NULL, created_at timestamptz NOT NULL,
UNIQUE(company_id,unit_id,actor_id,action,command_key),
FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE TABLE sr_events (
id bigserial PRIMARY KEY, command_id bigint NOT NULL UNIQUE REFERENCES sr_commands(id),
type text NOT NULL, schema_version integer NOT NULL CHECK(schema_version=1), payload jsonb NOT NULL, created_at timestamptz NOT NULL
);
CREATE TABLE sr_outbox (event_id bigint PRIMARY KEY REFERENCES sr_events(id), state text NOT NULL CHECK(state='captured_simulation_only'));
CREATE TABLE sr_deliveries (event_id bigint PRIMARY KEY REFERENCES sr_events(id), consumer text NOT NULL CHECK(consumer='simulation_integrity_v1'), created_at timestamptz NOT NULL);
CREATE OR REPLACE FUNCTION sgf_sr_environment() RETURNS void LANGUAGE plpgsql AS $$
BEGIN
IF current_database() IS DISTINCT FROM 'sgf_tempo_test' OR inet_server_port() IS DISTINCT FROM 55432 THEN RAISE EXCEPTION 'SGF_SR_TEST_DATABASE_ONLY'; END IF;
END; $$;
CREATE OR REPLACE FUNCTION sgf_sr_record_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE inv si_invoices; ob sr_records; rc sr_records; cid bigint; expected numeric;
BEGIN
PERFORM sgf_sr_environment();
PERFORM 1 FROM companies WHERE id=NEW.company_id FOR UPDATE;
SELECT * INTO STRICT inv FROM si_invoices WHERE id=NEW.invoice_id;
SELECT (snapshot->>'client_id')::bigint INTO STRICT cid FROM a_history
WHERE area='billing_drafts' AND record_id=inv.draft_id AND version=(inv.snapshot->'document'->>'draft_version')::integer
AND tenant_id=inv.tenant_id AND company_id=inv.company_id AND unit_id=inv.unit_id;
IF ROW(inv.tenant_id,inv.company_id,inv.unit_id,cid) IS DISTINCT FROM ROW(NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.client_id)
OR inv.snapshot->>'notice' IS DISTINCT FROM 'TESTE — SEM VALIDADE FISCAL'
OR inv.snapshot->>'simulation_only' IS DISTINCT FROM 'true'
OR inv.snapshot->'document'->'data'->>'currency' IS DISTINCT FROM NEW.currency
THEN RAISE EXCEPTION 'SGF_SR_DOCUMENT_SCOPE'; END IF;
IF NEW.data->>'policy' IS DISTINCT FROM 'SGF-RECEIVABLES-SIM-v1' THEN RAISE EXCEPTION 'SGF_SR_POLICY'; END IF;
IF NEW.kind='obligation' THEN
expected=(inv.snapshot->'calculation'->>'total_document_proposed')::numeric;
IF NEW.obligation_id IS NOT NULL OR NEW.receipt_id IS NOT NULL OR NEW.amount<>expected OR expected<=0 OR trunc(expected,2)<>expected THEN RAISE EXCEPTION 'SGF_SR_ORIGIN_AMOUNT'; END IF;
ELSE
SELECT * INTO STRICT ob FROM sr_records WHERE id=NEW.obligation_id AND kind='obligation';
IF ROW(ob.tenant_id,ob.company_id,ob.unit_id,ob.invoice_id,ob.client_id,ob.currency) IS DISTINCT FROM ROW(NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.invoice_id,NEW.client_id,NEW.currency)
OR NOT EXISTS(SELECT 1 FROM sr_effects WHERE record_id=ob.id AND kind='open') THEN RAISE EXCEPTION 'SGF_SR_OBLIGATION_SCOPE'; END IF;
IF NEW.kind='receipt' AND (NEW.amount<=0 OR NEW.receipt_id IS NOT NULL OR coalesce(NEW.data->>'evidence','')='' OR coalesce(NEW.data->>'date','')='') THEN RAISE EXCEPTION 'SGF_SR_RECEIPT'; END IF;
IF NEW.receipt_id IS NOT NULL THEN
SELECT * INTO STRICT rc FROM sr_records WHERE id=NEW.receipt_id AND kind='receipt';
IF rc.obligation_id<>ob.id THEN RAISE EXCEPTION 'SGF_SR_RECEIPT_SCOPE'; END IF;
END IF;
IF NEW.kind='observation' AND (NEW.receipt_id IS NULL OR coalesce(NEW.data->>'date','')='') THEN RAISE EXCEPTION 'SGF_SR_OBSERVATION'; END IF;
IF NEW.kind='correction' AND (NEW.data->>'action' NOT IN ('reverse_allocation','reverse_reconcile','reverse_receipt','reduction','due') OR coalesce(NEW.data->>'reason','')='') THEN RAISE EXCEPTION 'SGF_SR_CORRECTION'; END IF;
END IF;
RETURN NEW;
EXCEPTION WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'SGF_SR_INVALID_REFERENCE';
END; $$;
CREATE TRIGGER sr_record_guard BEFORE INSERT ON sr_records FOR EACH ROW EXECUTE FUNCTION sgf_sr_record_guard();
CREATE OR REPLACE FUNCTION sgf_sr_effect_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r sr_records; ob sr_records; rc sr_records; target sr_effects; balance numeric; available numeric; n integer; a text;
BEGIN
PERFORM sgf_sr_environment();
SELECT * INTO STRICT r FROM sr_records WHERE id=NEW.record_id;
PERFORM 1 FROM companies WHERE id=r.company_id FOR UPDATE;
SELECT * INTO STRICT ob FROM sr_records WHERE id=NEW.obligation_id AND kind='obligation';
IF (CASE WHEN r.kind='obligation' THEN r.id ELSE r.obligation_id END)<>ob.id THEN RAISE EXCEPTION 'SGF_SR_EFFECT_SCOPE'; END IF;
SELECT count(*) INTO n FROM sr_effects WHERE obligation_id=ob.id;
IF NEW.revision<>n THEN RAISE EXCEPTION 'SGF_SR_STALE_REVISION'; END IF;
IF NEW.kind IN ('open','receipt_confirm','refused','reconcile','divergence','reverse_allocation','reverse_reconcile','reverse_receipt','reduction','due') AND NEW.actor_id=r.actor_id THEN RAISE EXCEPTION 'SGF_SR_SELF_APPROVAL'; END IF;
IF NEW.kind='open' AND (r.kind<>'obligation' OR NEW.amount<>r.amount) THEN RAISE EXCEPTION 'SGF_SR_OPEN'; END IF;
IF NEW.kind='refused' AND (r.kind NOT IN ('obligation','receipt','correction') OR NEW.amount<>0) THEN RAISE EXCEPTION 'SGF_SR_REFUSE'; END IF;
IF NEW.kind='receipt_confirm' AND (r.kind<>'receipt' OR NEW.amount<>r.amount) THEN RAISE EXCEPTION 'SGF_SR_CONFIRM'; END IF;
IF NEW.receipt_id IS NOT NULL THEN
SELECT * INTO STRICT rc FROM sr_records WHERE id=NEW.receipt_id AND kind='receipt';
IF rc.obligation_id<>ob.id OR NOT EXISTS(SELECT 1 FROM sr_effects WHERE record_id=rc.id AND kind='receipt_confirm')
OR EXISTS(SELECT 1 FROM sr_effects WHERE receipt_id=rc.id AND kind='reverse_receipt') THEN RAISE EXCEPTION 'SGF_SR_RECEIPT_UNAVAILABLE'; END IF;
END IF;
IF NEW.kind='receipt_confirm' AND NEW.receipt_id IS NOT NULL THEN RAISE EXCEPTION 'SGF_SR_CONFIRM_LINK'; END IF;
IF NEW.kind='receipt_confirm' AND NEW.amount+(SELECT coalesce(sum(CASE WHEN kind='receipt_confirm' THEN amount WHEN kind='reverse_receipt' THEN -amount ELSE 0 END),0) FROM sr_effects WHERE obligation_id=ob.id)>=10000000000000000 THEN RAISE EXCEPTION 'SGF_SR_TOTAL_OVERFLOW'; END IF;
IF NEW.kind NOT IN ('reverse_allocation','reverse_reconcile') AND NEW.target_effect_id IS NOT NULL THEN RAISE EXCEPTION 'SGF_SR_UNEXPECTED_TARGET'; END IF;
IF NEW.kind NOT IN ('open','refused') AND NOT EXISTS(SELECT 1 FROM sr_effects WHERE record_id=ob.id AND kind='open') THEN RAISE EXCEPTION 'SGF_SR_NOT_OPEN'; END IF;
SELECT ob.amount-coalesce(sum(CASE WHEN kind IN ('allocate','reduction') THEN amount WHEN kind='reverse_allocation' THEN -amount ELSE 0 END),0) INTO balance FROM sr_effects WHERE obligation_id=ob.id;
IF NEW.kind='allocate' THEN
IF r.kind<>'receipt' OR NEW.receipt_id IS DISTINCT FROM r.id OR NEW.amount<=0 THEN RAISE EXCEPTION 'SGF_SR_ALLOCATION'; END IF;
SELECT rc.amount-coalesce(sum(CASE WHEN kind='allocate' THEN amount WHEN kind='reverse_allocation' THEN -amount ELSE 0 END),0) INTO available FROM sr_effects WHERE receipt_id=rc.id;
IF NEW.amount>balance OR NEW.amount>available THEN RAISE EXCEPTION 'SGF_SR_OVERALLOCATION'; END IF;
END IF;
IF NEW.kind IN ('reconcile','divergence') THEN
IF r.kind<>'observation' OR r.receipt_id IS DISTINCT FROM NEW.receipt_id THEN RAISE EXCEPTION 'SGF_SR_RECONCILE_SCOPE'; END IF;
IF NEW.kind='reconcile' AND (r.amount<>rc.amount OR NEW.amount<>rc.amount) THEN RAISE EXCEPTION 'SGF_SR_RECONCILE_AMOUNT'; END IF;
IF NEW.kind='divergence' AND (r.amount=rc.amount OR NEW.amount<>abs(r.amount-rc.amount)) THEN RAISE EXCEPTION 'SGF_SR_DIVERGENCE'; END IF;
IF EXISTS(SELECT 1 FROM sr_effects e WHERE e.kind='reconcile' AND (e.receipt_id=rc.id OR e.record_id=r.id) AND NOT EXISTS(SELECT 1 FROM sr_effects v WHERE v.target_effect_id=e.id)) THEN RAISE EXCEPTION 'SGF_SR_OBSERVATION_REUSED'; END IF;
END IF;
IF NEW.kind IN ('reverse_allocation','reverse_reconcile','reverse_receipt','reduction','due') THEN
IF r.kind<>'correction' OR r.data->>'action' IS DISTINCT FROM NEW.kind OR (r.data->>'revision')::integer IS DISTINCT FROM n THEN RAISE EXCEPTION 'SGF_SR_CORRECTION_VERSION'; END IF;
IF NEW.kind IN ('reverse_allocation','reverse_reconcile') THEN
SELECT * INTO STRICT target FROM sr_effects WHERE id=NEW.target_effect_id AND obligation_id=ob.id;
IF target.kind<>(CASE WHEN NEW.kind='reverse_allocation' THEN 'allocate' ELSE 'reconcile' END) OR NEW.amount<>target.amount OR NEW.receipt_id IS DISTINCT FROM target.receipt_id OR (r.data->>'target_effect_id')::bigint IS DISTINCT FROM target.id THEN RAISE EXCEPTION 'SGF_SR_REVERSAL_TARGET'; END IF;
ELSIF NEW.target_effect_id IS NOT NULL THEN RAISE EXCEPTION 'SGF_SR_UNEXPECTED_TARGET'; END IF;
IF NEW.kind='reverse_receipt' THEN
IF r.receipt_id IS DISTINCT FROM NEW.receipt_id OR rc.id IS NULL OR NEW.amount<>rc.amount THEN RAISE EXCEPTION 'SGF_SR_REVERSAL_RECEIPT'; END IF;
SELECT coalesce(sum(CASE WHEN kind='allocate' THEN amount WHEN kind='reverse_allocation' THEN -amount ELSE 0 END),0) INTO available FROM sr_effects WHERE receipt_id=rc.id;
IF available<>0 OR EXISTS(SELECT 1 FROM sr_effects e WHERE e.receipt_id=rc.id AND e.kind='reconcile' AND NOT EXISTS(SELECT 1 FROM sr_effects v WHERE v.target_effect_id=e.id)) THEN RAISE EXCEPTION 'SGF_SR_REVERSAL_DEPENDENCIES'; END IF;
END IF;
IF NEW.kind='reduction' AND (NEW.amount<=0 OR NEW.amount<>r.amount OR NEW.amount>balance OR coalesce(r.data->>'document_ref','') NOT LIKE 'TEST-REDUCTION-%' OR r.data->>'line' IS DISTINCT FROM '1') THEN RAISE EXCEPTION 'SGF_SR_REDUCTION'; END IF;
IF NEW.kind='due' AND (NEW.amount<>0 OR NEW.data->>'due_date' IS DISTINCT FROM r.data->>'due_date') THEN RAISE EXCEPTION 'SGF_SR_DUE'; END IF;
END IF;
IF NEW.kind IN ('open','refused','receipt_confirm','reduction','due') AND NEW.receipt_id IS NOT NULL THEN RAISE EXCEPTION 'SGF_SR_UNEXPECTED_RECEIPT'; END IF;
RETURN NEW;
EXCEPTION WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'SGF_SR_INVALID_REFERENCE';
END; $$;
CREATE TRIGGER sr_effect_guard BEFORE INSERT ON sr_effects FOR EACH ROW EXECUTE FUNCTION sgf_sr_effect_guard();
CREATE OR REPLACE FUNCTION sgf_sr_test_insert() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN PERFORM sgf_sr_environment(); RETURN NEW; END; $$;
+162
View File
@@ -0,0 +1,162 @@
CREATE TABLE wo_equipment (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, client_id bigint NOT NULL REFERENCES a_clients(id),
identifier text NOT NULL, description text NOT NULL, serial text, actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL,
UNIQUE(company_id,identifier), FOREIGN KEY(tenant_id,company_id) REFERENCES companies(tenant_id,id)
);
CREATE TABLE wo_orders (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, unit_id bigint NOT NULL,
client_id bigint NOT NULL REFERENCES a_clients(id), equipment_id bigint NOT NULL REFERENCES wo_equipment(id),
reference text NOT NULL, request text NOT NULL, location text NOT NULL, evidence text NOT NULL,
actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL,
UNIQUE(company_id,reference), FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE TABLE wo_entries (
id bigserial PRIMARY KEY, order_id bigint NOT NULL REFERENCES wo_orders(id),
kind text NOT NULL CHECK(kind IN ('assignment','task','authorization','start','time','material')),
origin text NOT NULL, version integer NOT NULL CHECK(version>0), previous_id bigint UNIQUE REFERENCES wo_entries(id),
task_id bigint REFERENCES wo_entries(id), authorization_id bigint REFERENCES wo_entries(id), start_id bigint REFERENCES wo_entries(id),
technician_id bigint REFERENCES users(id), starts_at timestamptz, ends_at timestamptz, minutes bigint,
quantity numeric CHECK(quantity>0 AND quantity<10000000000000000 AND quantity=trunc(quantity,8)),
outside_authority boolean NOT NULL DEFAULT false, revision integer NOT NULL CHECK(revision>=0), data jsonb NOT NULL,
actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL,
UNIQUE(order_id,kind,origin,version)
);
CREATE UNIQUE INDEX wo_start_completion ON wo_entries(start_id) WHERE start_id IS NOT NULL;
CREATE INDEX wo_order_entries ON wo_entries(order_id,id);
CREATE TABLE wo_decisions (
id bigserial PRIMARY KEY, entry_id bigint NOT NULL UNIQUE REFERENCES wo_entries(id),
entry_version integer NOT NULL, revision integer NOT NULL, decision text NOT NULL CHECK(decision IN ('verified','refused','acknowledged')),
reason text NOT NULL, actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL
);
CREATE TABLE wo_commands (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, unit_id bigint NOT NULL,
actor_id bigint NOT NULL REFERENCES users(id), action text NOT NULL, command_key varchar(100) NOT NULL, request_hash varchar(64) NOT NULL,
order_id bigint NOT NULL REFERENCES wo_orders(id), result jsonb NOT NULL, created_at timestamptz NOT NULL,
UNIQUE(company_id,unit_id,actor_id,action,command_key), FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE TABLE wo_events (
id bigserial PRIMARY KEY, command_id bigint NOT NULL UNIQUE REFERENCES wo_commands(id), type text NOT NULL,
schema_version integer NOT NULL CHECK(schema_version=1), payload jsonb NOT NULL, created_at timestamptz NOT NULL
);
CREATE TABLE wo_outbox (event_id bigint PRIMARY KEY REFERENCES wo_events(id), state text NOT NULL CHECK(state='captured_no_cost_stock_or_billing'));
CREATE OR REPLACE FUNCTION sgf_wo_environment() RETURNS void LANGUAGE plpgsql AS $$
BEGIN
IF current_database() IS DISTINCT FROM 'sgf_tempo_test' OR inet_server_port() IS DISTINCT FROM 55432 THEN RAISE EXCEPTION 'SGF_WO_TEST_DATABASE_ONLY'; END IF;
END; $$;
CREATE OR REPLACE FUNCTION sgf_wo_test_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN PERFORM sgf_wo_environment(); RETURN NEW; END; $$;
CREATE OR REPLACE FUNCTION sgf_wo_revision(oid bigint) RETURNS integer LANGUAGE sql VOLATILE AS $$
SELECT (SELECT count(*) FROM wo_entries WHERE order_id=oid)::integer+(SELECT count(*) FROM wo_decisions d JOIN wo_entries e ON e.id=d.entry_id WHERE e.order_id=oid)::integer;
$$;
CREATE OR REPLACE FUNCTION sgf_wo_parent_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE eq wo_equipment;
BEGIN
PERFORM sgf_wo_environment();
PERFORM 1 FROM companies WHERE id=NEW.company_id FOR UPDATE;
IF NOT EXISTS(SELECT 1 FROM a_clients WHERE id=NEW.client_id AND tenant_id=NEW.tenant_id AND company_id=NEW.company_id) THEN RAISE EXCEPTION 'SGF_WO_CLIENT_SCOPE'; END IF;
IF TG_TABLE_NAME='wo_orders' THEN
SELECT * INTO STRICT eq FROM wo_equipment WHERE id=NEW.equipment_id;
IF ROW(eq.tenant_id,eq.company_id,eq.client_id) IS DISTINCT FROM ROW(NEW.tenant_id,NEW.company_id,NEW.client_id) THEN RAISE EXCEPTION 'SGF_WO_EQUIPMENT_SCOPE'; END IF;
END IF;
RETURN NEW;
EXCEPTION WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'SGF_WO_INVALID_REFERENCE';
END; $$;
CREATE TRIGGER wo_equipment_scope BEFORE INSERT ON wo_equipment FOR EACH ROW EXECUTE FUNCTION sgf_wo_parent_guard();
CREATE TRIGGER wo_order_scope BEFORE INSERT ON wo_orders FOR EACH ROW EXECUTE FUNCTION sgf_wo_parent_guard();
CREATE OR REPLACE FUNCTION sgf_wo_entry_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE o wo_orders; p wo_entries; t wo_entries; au wo_entries; st wo_entries; x wo_entries; assigned bigint; pause jsonb; last_end timestamptz; pause_seconds numeric:=0; duration numeric; item text; used numeric; provided numeric; event_kind text;
BEGIN
PERFORM sgf_wo_environment();
SELECT * INTO STRICT o FROM wo_orders WHERE id=NEW.order_id;
PERFORM 1 FROM companies WHERE id=o.company_id FOR UPDATE;
IF NEW.revision<>sgf_wo_revision(o.id) OR NEW.data->>'policy' IS DISTINCT FROM 'WORKSHOP-RESOURCE-RECORD-v1' THEN RAISE EXCEPTION 'SGF_WO_STALE_REVISION'; END IF;
IF NEW.previous_id IS NOT NULL THEN
SELECT * INTO STRICT p FROM wo_entries WHERE id=NEW.previous_id;
IF ROW(p.order_id,p.kind,p.origin) IS DISTINCT FROM ROW(NEW.order_id,NEW.kind,NEW.origin) OR NEW.version<>p.version+1
OR EXISTS(SELECT 1 FROM wo_entries WHERE previous_id=p.id) OR coalesce(NEW.data->>'correction_reason','')='' THEN RAISE EXCEPTION 'SGF_WO_VERSION_CHAIN'; END IF;
IF NEW.kind IN ('task','time','material') AND p.actor_id<>NEW.actor_id THEN RAISE EXCEPTION 'SGF_WO_ORIGIN_AUTHOR'; END IF;
ELSE
IF NEW.version<>1 THEN RAISE EXCEPTION 'SGF_WO_VERSION'; END IF;
END IF;
SELECT technician_id INTO assigned FROM wo_entries WHERE order_id=o.id AND kind='assignment' ORDER BY id DESC LIMIT 1;
IF NEW.kind='assignment' THEN
IF NEW.technician_id IS NULL OR NOT EXISTS(SELECT 1 FROM permission_grants WHERE user_id=NEW.technician_id AND tenant_id=o.tenant_id AND company_id=o.company_id AND unit_id=o.unit_id AND permission='workshop.record')
OR NOT EXISTS(SELECT 1 FROM company_memberships WHERE user_id=NEW.technician_id AND company_id=o.company_id AND tenant_id=o.tenant_id) THEN RAISE EXCEPTION 'SGF_WO_ASSIGNEE'; END IF;
END IF;
IF NEW.kind IN ('task','start','time','material') AND (assigned IS DISTINCT FROM NEW.actor_id OR NEW.technician_id IS DISTINCT FROM NEW.actor_id) THEN RAISE EXCEPTION 'SGF_WO_ASSIGNED_TECHNICIAN'; END IF;
IF NEW.kind='task' THEN
IF coalesce(NEW.data->>'description','')='' THEN RAISE EXCEPTION 'SGF_WO_TASK'; END IF;
IF NEW.data->>'service_id' IS NOT NULL AND NOT EXISTS(SELECT 1 FROM a_services WHERE id=(NEW.data->>'service_id')::bigint AND company_id=o.company_id AND tenant_id=o.tenant_id) THEN RAISE EXCEPTION 'SGF_WO_SERVICE_SCOPE'; END IF;
END IF;
IF NEW.kind='authorization' THEN
IF NEW.task_id IS NULL OR coalesce(NEW.data->>'authorized_by','')='' OR coalesce(NEW.data->>'capacity','')='' OR coalesce(NEW.data->>'authorized_at','')='' OR coalesce(NEW.data->>'channel','')='' OR coalesce(NEW.data->>'scope','')='' OR coalesce(NEW.data->>'evidence','')='' THEN RAISE EXCEPTION 'SGF_WO_AUTH_EVIDENCE'; END IF;
IF (NEW.data->>'authorized_at')::timestamptz>NEW.created_at THEN RAISE EXCEPTION 'SGF_WO_FUTURE_AUTH'; END IF;
END IF;
IF NEW.kind IN ('authorization','start','time','material') THEN
SELECT * INTO STRICT t FROM wo_entries WHERE id=NEW.task_id AND order_id=o.id AND kind='task';
IF EXISTS(SELECT 1 FROM wo_entries WHERE previous_id=t.id) AND NEW.kind IN ('authorization','start') THEN RAISE EXCEPTION 'SGF_WO_STALE_TASK'; END IF;
END IF;
IF NEW.kind IN ('start','time','material') THEN
NEW.outside_authority:=true;
IF NEW.authorization_id IS NOT NULL THEN
SELECT * INTO STRICT au FROM wo_entries WHERE id=NEW.authorization_id AND order_id=o.id AND kind='authorization' AND task_id=t.id;
IF au.created_at<=coalesce(NEW.starts_at,NEW.created_at) AND (au.data->>'authorized_at')::timestamptz<=coalesce(NEW.starts_at,NEW.created_at)
AND NOT EXISTS(SELECT 1 FROM wo_entries z WHERE z.previous_id=au.id AND z.created_at<=coalesce(NEW.ends_at,NEW.starts_at,NEW.created_at))
AND NOT EXISTS(SELECT 1 FROM wo_entries z WHERE z.previous_id=t.id AND z.created_at<=coalesce(NEW.ends_at,NEW.starts_at,NEW.created_at)) THEN NEW.outside_authority:=false; END IF;
END IF;
IF coalesce(NEW.data->>'evidence','')='' THEN RAISE EXCEPTION 'SGF_WO_SOURCE_EVIDENCE'; END IF;
IF NEW.previous_id IS NOT NULL AND p.outside_authority THEN NEW.outside_authority:=true; END IF;
END IF;
IF NEW.kind='start' THEN
IF NEW.outside_authority OR NEW.starts_at IS NULL OR NEW.starts_at<>NEW.created_at THEN RAISE EXCEPTION 'SGF_WO_START_NOT_AUTHORIZED'; END IF;
IF EXISTS(SELECT 1 FROM wo_entries e JOIN wo_orders w ON w.id=e.order_id WHERE w.company_id=o.company_id AND e.technician_id=NEW.technician_id AND e.kind='start' AND NOT EXISTS(SELECT 1 FROM wo_entries done WHERE done.start_id=e.id)) THEN RAISE EXCEPTION 'SGF_WO_ACTIVE_START'; END IF;
END IF;
IF NEW.kind='time' THEN
IF coalesce(NEW.data->>'time_type','') NOT IN ('work','travel') OR jsonb_typeof(NEW.data->'pauses') IS DISTINCT FROM 'array' THEN RAISE EXCEPTION 'SGF_WO_TIME_TYPE'; END IF;
IF NEW.starts_at IS NULL OR NEW.ends_at IS NULL OR NEW.ends_at<=NEW.starts_at OR NEW.ends_at>NEW.created_at THEN RAISE EXCEPTION 'SGF_WO_TIME_RANGE'; END IF;
IF NEW.start_id IS NOT NULL THEN
SELECT * INTO STRICT st FROM wo_entries WHERE id=NEW.start_id AND order_id=o.id AND kind='start' AND technician_id=NEW.technician_id;
IF st.task_id<>NEW.task_id OR st.authorization_id IS DISTINCT FROM NEW.authorization_id OR st.starts_at<>NEW.starts_at THEN RAISE EXCEPTION 'SGF_WO_START_LINK'; END IF;
ELSIF NEW.data->>'mode' IS DISTINCT FROM 'reported' THEN RAISE EXCEPTION 'SGF_WO_START_REQUIRED'; END IF;
last_end:=NEW.starts_at;
FOR pause IN SELECT value FROM jsonb_array_elements(NEW.data->'pauses') LOOP
IF (pause->>'start')::timestamptz<last_end OR (pause->>'end')::timestamptz<=(pause->>'start')::timestamptz OR (pause->>'end')::timestamptz>NEW.ends_at THEN RAISE EXCEPTION 'SGF_WO_PAUSES'; END IF;
pause_seconds:=pause_seconds+extract(epoch FROM ((pause->>'end')::timestamptz-(pause->>'start')::timestamptz)); last_end:=(pause->>'end')::timestamptz;
END LOOP;
duration:=extract(epoch FROM (NEW.ends_at-NEW.starts_at))-pause_seconds;
IF duration IS NULL OR NEW.minutes IS NULL OR duration<=0 OR mod(duration,60)<>0 OR NEW.minutes IS DISTINCT FROM (duration/60)::bigint THEN RAISE EXCEPTION 'SGF_WO_EFFECTIVE_MINUTES'; END IF;
-- Conservative occupied interval, half-open: pauses do not permit a second simultaneous task.
IF EXISTS(SELECT 1 FROM wo_entries e JOIN wo_orders w ON w.id=e.order_id WHERE e.kind='time' AND w.company_id=o.company_id AND e.technician_id=NEW.technician_id
AND NOT EXISTS(SELECT 1 FROM wo_entries z WHERE z.previous_id=e.id) AND e.id IS DISTINCT FROM NEW.previous_id
AND tstzrange(e.starts_at,e.ends_at,'[)') && tstzrange(NEW.starts_at,NEW.ends_at,'[)')) THEN RAISE EXCEPTION 'SGF_WO_TIME_OVERLAP'; END IF;
END IF;
IF NEW.kind='material' THEN
IF NEW.quantity IS NULL OR NEW.starts_at IS NULL OR NEW.starts_at>NEW.created_at OR coalesce(NEW.data->>'event','') NOT IN ('required','delivered','consumed','returned') OR coalesce(NEW.data->>'owner','') NOT IN ('company','client') OR coalesce(NEW.data->>'resource','')='' OR coalesce(NEW.data->>'unit','') NOT IN ('UN','KG','M','L') THEN RAISE EXCEPTION 'SGF_WO_MATERIAL'; END IF;
-- resource identity, owner and UOM cannot change silently for one material reference.
IF EXISTS(SELECT 1 FROM wo_entries e WHERE e.order_id=o.id AND e.kind='material' AND e.data->>'resource'=NEW.data->>'resource'
AND ROW(e.data->>'owner',e.data->>'unit') IS DISTINCT FROM ROW(NEW.data->>'owner',NEW.data->>'unit')) THEN RAISE EXCEPTION 'SGF_WO_MATERIAL_UNIT_OWNER'; END IF;
IF NEW.previous_id IS NOT NULL AND ROW(p.data->>'resource',p.data->>'owner',p.data->>'unit',p.data->>'event') IS DISTINCT FROM ROW(NEW.data->>'resource',NEW.data->>'owner',NEW.data->>'unit',NEW.data->>'event') THEN RAISE EXCEPTION 'SGF_WO_MATERIAL_ORIGIN'; END IF;
IF NEW.data->>'event'<>'consumed' AND NOT coalesce(p.outside_authority,false) THEN NEW.outside_authority:=false; END IF;
END IF;
RETURN NEW;
EXCEPTION WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'SGF_WO_INVALID_REFERENCE';
END; $$;
CREATE TRIGGER wo_entry_guard BEFORE INSERT ON wo_entries FOR EACH ROW EXECUTE FUNCTION sgf_wo_entry_guard();
CREATE OR REPLACE FUNCTION sgf_wo_decision_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE e wo_entries; o wo_orders;
BEGIN
PERFORM sgf_wo_environment();
SELECT * INTO STRICT e FROM wo_entries WHERE id=NEW.entry_id;
SELECT * INTO STRICT o FROM wo_orders WHERE id=e.order_id;
PERFORM 1 FROM companies WHERE id=o.company_id FOR UPDATE;
IF e.kind NOT IN ('task','time','material') OR NEW.actor_id=e.actor_id THEN RAISE EXCEPTION 'SGF_WO_SELF_VERIFICATION'; END IF;
IF NEW.entry_version<>e.version OR EXISTS(SELECT 1 FROM wo_entries WHERE previous_id=e.id) OR NEW.revision<>sgf_wo_revision(o.id) THEN RAISE EXCEPTION 'SGF_WO_STALE_DECISION'; END IF;
IF e.outside_authority AND NEW.decision='verified' THEN RAISE EXCEPTION 'SGF_WO_OUTSIDE_AUTHORITY'; END IF;
IF NEW.decision='acknowledged' AND NOT e.outside_authority THEN RAISE EXCEPTION 'SGF_WO_ACK_ONLY_EXCEPTION'; END IF;
RETURN NEW;
EXCEPTION WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'SGF_WO_INVALID_REFERENCE';
END; $$;
CREATE TRIGGER wo_decision_guard BEFORE INSERT ON wo_decisions FOR EACH ROW EXECUTE FUNCTION sgf_wo_decision_guard();
+222
View File
@@ -0,0 +1,222 @@
# Contabilidade de custos industriais — proposta documental
27/10/2026, Africa/Johannesburg. Versão 0.1. **Não autoriza implementação, dados operacionais, migrações, concessões ou lançamentos.**
## 1. Objectivo, base e limites
Conhecer o custo de produzir, explicar cada componente e comparar previsto com realizado, com fontes/versões e pendências visíveis. Não inferir IVA, reconhecimento de receita, valorização legal de inventários ou lançamento a partir de uma classificação de gestão.
Projecto exclusivo `C:\laragon\www\sgf-tempo`, com instruções locais lidas. Preservar `4276aca`, alterações posteriores e [recebimentos sintéticos](SERVICE_RECEIVABLES_DELIVERY.md), sem presumir aceitação dessa entrega. Não alterar os bloqueios de emissão/configuração real ou recebimentos reais. Não repetir o cotejo fiscal do InvesteMoz.
Esta proposta foi preparada **depois da especificação de [oficinas/assistência](WORKSHOP_MANAGEMENT_SCOPE.md)**, que foi lida e constitui desenho ainda não implementado. A referência geral continua a ser o roteiro **v0.9**, especialmente núcleo universal/extensões sectoriais, indústria, registo único, regras versionadas e contabilidade permanente. A indústria ali prevista não demonstra compras, stock, produção ou custeio já operacionais.
O primeiro ensaio recomendado é uma empresa/unidade sintéticas, um período, um produto, uma ordem/lote concluído, uma moeda explícita e uma única etapa. Não se conhece ainda a indústria real destinatária. Não adoptar um método empresarial ou um plano de contas por analogia com a Tempo.
## 2. Fontes contabilísticas disponíveis e alcance
| Fonte efectivamente disponível | Uso delimitado nesta proposta | O que não demonstra |
| --- | --- | --- |
| `SGF_TEMPO_Roteiro_v0_9.docx`, exemplar fornecido | Indústria com estruturas de produto, ordens, consumos, produtos obtidos, custo/qualidade/rastreabilidade; núcleo e reutilização sem recaptura | Processo produtivo, método de custeio ou integrações efectivas de uma indústria real |
| [Classificação contabilística](CATALOGUE_ACCOUNTING_CLASSIFICATION_SPEC.md) e [entrega](CATALOGUE_ACCOUNTING_DELIVERY.md) | Separação entre plano/conta, natureza do recurso, reconhecimento e IVA; fontes/versionamento e âmbito empresarial | Catálogo industrial de contas, centros de custo, valorização de stock ou regras de lançamento já aprovados |
| Decreto 70/2009 fornecido, `\\Mac\Home\Downloads\29-Plano-de-Contas-BR-Decreto-70-2009-de-22-de-Dezembro.pdf` | Exemplar histórico já identificado, SHA-256 `45C38AED64DE43042ED048586ACCE2EC660B74FC3F8FBCE33D39AAB45C7529E3`. Leitura dirigida adicional da **NCRF 9 — Inventários**, PDF 36–37, BR 372-(50)–(51): §§7–14 sobre mensuração/componentes/capacidade; §§15–17 sobre técnicas/fórmulas; §23 sobre reconhecimento como gasto | Não é consolidação normativa actual verificada, aprovação de política para empresa industrial ou autorização de activar valorização/lançamentos |
| Capas dos livros partilhados | Identificação bibliográfica potencial | Capítulos não fornecidos: não se lhes atribuem fórmulas, métodos, exemplos ou ensinamentos não consultados |
O exemplar histórico distingue custos de compra/transformação/outros e trata capacidade normal na imputação de gastos industriais fixos (§§8–12). O §11 distingue a parcela fixa não imputada; a proposta de gestão deve conservá-la, sem a forçar para o produto. §§16–17 tratam fórmulas de custo de inventários, matéria diferente de escolher um driver de custos indirectos. §23 separa custo do inventário e momento de reconhecimento como gasto. Estas referências justificam manter conceitos distintos; não aprovam a política da indústria futura.
Lacunas normativas específicas para eventual uso real: identificar entidade/sector e referencial aplicável; verificar vigência/alterações apenas das disposições que serão activadas; validar custo elegível, capacidade normal, perdas/retrabalho, produção em curso, fórmula de inventário e reconhecimento. **Não bloqueiam a elaboração do ensaio sintético**, que não declara conformidade de valorização. A aplicação PGC-NIRF confirmada para a Tempo não se propaga a todas as empresas futuras.
## 3. Conceitos que não se substituem
| Conceito | Definição operacional proposta |
| --- | --- |
| Custo de produção de gestão | Recursos elegíveis atribuídos/imputados ao objecto sob política explícita, no corte/versionamento do cálculo. Pode ser parcial ou provisório |
| Custo de produção unitário | Custo atribuído ao lote / quantidade de produto elegível, na mesma definição e corte. Denominador zero/desconhecido não produz zero por unidade |
| Custo das vendas | Parcela do custo referente às unidades vendidas segundo política de inventário/reconhecimento própria. Não equivale ao custo de todo o lote produzido ou ao valor facturado |
| Despesa do período | Recurso destinado ao período segundo política validada, sem o capitalizar só para completar custo do produto. Pode incluir parcelas não imputadas conforme regras aplicáveis, ainda sem lançamento |
| Preço de venda | Valor comercial aprovado, independente de custo; não valoriza material ou mão-de-obra por ausência de custo |
| Margem | Indicador que identifica receita/preço, volume vendido, custo das vendas e despesas incluídas/excluídas. Sem esse perímetro completo, apenas diferença parcial identificada; nunca «lucro» comprovado |
| Previsto | Versão de plano anterior ao corte, com quantidades, taxas, produção e critérios estimados. Não é consumo real |
| Realizado | Quantidades observadas/verificadas; a sua valorização pode ainda ser provisória/desconhecida. «Executado» não significa custo validado |
Classificação directa/indirecta é relativa ao objecto de custo e à política, não só à natureza ou conta. O mesmo técnico/recurso pode ter horas directas e indirectas **distintas**, sem classificar o mesmo intervalo integralmente nos dois lados. Método de acumulação (ordem/processo), perímetro de custo (absorção/variável), técnica de mensuração (real/padrão) e base de repartição/ABC são dimensões diferentes, não alternativas de uma única lista.
## 4. Alternativas necessárias e recorte recomendado
| Dimensão | Alternativas relevantes | Recorte sintético recomendado e limite |
| --- | --- | --- |
| Acumulação | Por ordem/lote identificado; por processo homogéneo/período | **Ordem/lote**, por permitir ligar poucos recursos concretos. Processo exige indústria/etapas e fluxos ainda não identificados |
| Perímetro | Custos directos com CIF elegíveis explícitos; análise variável/contribuição | Directos + **um conjunto de CIF sintéticos identificado**, sem dizer que todo custo empresarial é absorvido. Custeio variável é outra análise, não valorização legal presumida |
| Imputação | Driver simples com causalidade declarada; actividades/ABC | Um driver em horas-máquina e um critério versionado. ABC só se actividades/consumos justificarem extensão |
| Valorização | Custos específicos verificados; dados de inventário/folha/activos sob política | Taxas/valores sintéticos documentados, sem seleccionar FIFO, média ou política salarial para empresa real |
Recomenda-se demonstrar a aritmética de gestão por ordem, com fontes explícitas e residual. **Não é decisão de adopção de custeio por encomenda, absorção ou método legal numa indústria real.** Nenhum pressuposto da fixture se torna configuração por omissão.
## 5. Objectos de custo, entidades e dados
| Entidade | Conteúdo mínimo e vínculos |
| --- | --- |
| Produto | ID empresarial, designação, unidade do produto, versão/especificação e categoria existente quando aplicável. Catálogo de serviços não deve ser renomeado para fingir produto/stock |
| Ordem/lote de produção | ID próprio, tenant/empresa/unidade, produto, quantidade prevista, período e corte, datas, versão, etapa única e responsável. Não é ordem de oficina nem documento fiscal |
| Centro de custo | Identidade empresarial, unidade(s) autorizadas, finalidade, vigência e responsável. Centro é dimensão analítica, não unidade física/empresa por omissão |
| Plano de custos | Versão base: quantidades/unidades, custos unitários, critérios de CIF, produção prevista e pressupostos; aprovação e data de corte. Comparativo preserva esta versão, sem a substituir pelo realizado |
| Acontecimento de recurso | ID canónico/origem/versão, tipo consumo/devolução/tempo/serviço, quantidade, unidade, recurso, ordem/tarefa/contexto, data efectiva/registro, evidência, autor e estado verificado |
| Valorização de recurso | ID e versão de cada origem, quantidade elegível, taxa/preço de custo, moeda, unidade da taxa, fonte, vigência e estado conhecido/provisório/em falta/não aplicável. Guardar composição quando houver encargos |
| Destino de custo directo | Referência da origem, ordem/lote e papel, quantidade/valor atribuído e critério. Não criar outra origem ou consumo ao atribuir |
| Conjunto de custos indirectos | IDs de parcelas elegíveis, centro/empresa/unidade, período, natureza fixa/variável quando conhecida, total exacto e exclusões/motivos. Conjunto não é conta contabilística automática |
| Critério de imputação | Versão, âmbito/vigência, conjunto, driver/unidade, denominador, capacidade quando relevante, numeradores por destino, fórmula, limites, representação e tratamento de residual |
| Execução de imputação | ID, conjunto/critério/versões exactas, corte, bases, parcelas por destino, residual e pendências; aprovação e estado. Uma origem não é aplicada outra vez por repetir o comando |
| Produção e qualidade | Evento próprio de quantidade obtida, unidades boas, rejeitadas, perda/retrabalho, unidade, data, evidência e validação. Produção em curso distinta de acabado e de venda |
| Cálculo de custo | ID/versionamento, dependências/origens, componentes directos/CIF, quantidade elegível, custo exacto/unitário, residual, perímetro, exclusões e estado de completude |
| Reconciliação de gestão | Corte e perímetro das origens, directo, destinado aos conjuntos, imputado, não imputado, excluído/por classificar e diferenças; cálculo preservado e autor |
Empresa/unidade/período/moeda/unidade de medida são obrigatórios antes de um cálculo ser considerado verificável. Não há taxa de custo, centro, política ou moeda funcional universais. Um valor de origem inclui identidade e propósito; não basta mesma descrição/valor/data. Um documento de origem pode conter parcelas com finalidades diferentes, cada uma identificada sem duplicação.
## 6. Acontecimentos, estados e dependências
- **Planeamento/necessidade** não é reserva/entrega/consumo. **Devolução de material não usado** reduz o bruto entregue, não cancela material efectivamente consumido. Não subtrair devolução duas vezes se o consumo já veio líquido.
- **Tempo de trabalho directo**, pausa, deslocação, paragem e tempo de máquina são ocorrências/unidades diferentes. Tempo decorrido não é hora-pessoa ou hora-máquina. Reutilizar as validações propostas em oficinas, sem copiar cada apontamento para um módulo novo.
- **Produção obtida**, classificação em boa/rejeitada, perda física e retrabalho são acontecimentos ligados, não unidades automaticamente equivalentes. Uma reclassificação não cria segunda produção. Retrabalho acrescenta recurso próprio ao alvo identificado; normalidade/elegibilidade de perda exigem política.
- **Produção em curso** significa recursos aplicados a trabalho ainda não concluído no corte. Não a declarar produto acabado nem custo unitário final. Neste primeiro cálculo apenas lote concluído, sem WIP inicial/final; caso em curso fica pendente e fora do ensaio fechado.
- **Venda/entrega/factura/recebimento** ficam fora do custeio de produção deste recorte. Custo calculado não cria saída de inventário, custo das vendas, receita ou recebimento.
Estados propostos: origem declarada → verificada operacionalmente; valorização pendente/provisória/validada; critério rascunho → submetido → aprovado/recusado; cálculo preparado → condicionado ou verificável → aprovado. Alterar uma origem/critério gera nova versão e cálculo novo; a versão aprovada anterior permanece como histórica, marcada como substituída/desactualizada para consulta actual quando necessário. Nunca reabrir período ou recalcular silenciosamente o aprovado.
| Dependência ainda não entregue | Consequência no primeiro recorte |
| --- | --- |
| Compras/inventário e valorização por movimento | Material usa medição e taxa sintéticas identificadas; não declara stock, disponibilidade ou custo de aquisição real |
| Folha salarial/encargos | Taxa laboral sintética por unidade; no real, composição e base de horas precisam de suporte. Não usar preço de venda/hora ou expor remunerações ao técnico |
| Activos/amortizações/manutenção/energia | Conjunto CIF de teste com valores/parcelas artificiais; não gerar amortização, factura de energia ou despesa real |
| Planeamento/estruturas de produto/controlo de produção | Um lote e especificação mínima sintéticos; BOM, MRP, múltiplas etapas, produção conjunta/subprodutos e planeamento de capacidade detalhado ficam fora |
| Motor de custeio/centros/imputações | São novos; a classificação contabilística actual não fornece este motor |
## 7. Regras de imputação, exactidão e reconciliação
1. **Uma origem, um acontecimento:** registar recurso uma vez e ligar suas parcelas aos destinos. Utilização por ordem de oficina ou industrial é uma dimensão explícita; não há conversão automática entre ordens. Se uma ordem técnica prestar manutenção a uma linha fabril, ligar o custo elegível como fonte/serviço interno por operação aprovada, sem duplicar o consumo/hora já existente.
2. **Sem dupla elegibilidade:** uma mesma parcela não pode estar integralmente em custo directo e no conjunto CIF, em dois conjuntos ou em duas execuções activas. Repartição legítima conserva origem, quantidade e soma das percentagens/parcelas limitada ao elegível; transferir classificação exige nova versão/reversão rastreável.
3. **Perímetro antes da taxa:** conjunto lista origens elegíveis, excluídas e por validar. Não misturar unidades, moedas, empresas ou períodos. Ausência de uma origem prevista não é custo zero. Um pool validado mas incompleto não suporta custo de produção completo.
4. **Driver explícito:** `taxa = custo elegível do conjunto / denominador validado`; `parcela do destino = taxa × base elegível do destino`. Declarar se denominador é capacidade normal, base real ou outra base fundamentada. Denominador desconhecido/zero bloqueia imputação; não dividir só pelos destinos conhecidos para consumir tudo.
5. **Capacidade:** fixos/variáveis e capacidade são dados/critério aprovados, não inferidos do nome da conta. Excesso de utilização/capacidade, custo sobreimputado ou destino fora da vigência produzem pendência/conflito; não aceitar saldo negativo no conjunto ou redefinir capacidade automaticamente.
6. **Residual de gestão:** `origem elegível do conjunto = imputado + não imputado`, no mesmo corte. O não imputado tem motivo (capacidade não usada, destino ausente, base em falta, exclusão ou arredondamento de representação); não é todo automaticamente custo do produto. A classificação contabilística futura do residual depende da política aplicável; conservá-lo não dispensa essa avaliação.
7. **Precisão:** reutilizar aritmética exacta/Brick Math; quantidades, taxas e montantes em cadeias decimais controladas, sem float ou redução silenciosa de escala. Não propagar a truncagem operacional do IVA aos custos. Divisões não finitas exigem representação racional exacta ou política explícita ainda a aprovar, não arredondamento oculto.
8. **Resíduo de representação separado:** exemplo de limite aritmético: 100/3 em três destinos, se apresentado como 33,33 cada, dá 99,99 apresentados + 0,01 de diferença de representação. Isso não é mais um custo ou capacidade ociosa. Conservar 100/3 exacto e a diferença; não atribuir automaticamente o cêntimo ao último destino. O método de distribuição monetária, se necessário, exige política versionada. O exemplo principal evita esta ambiguidade.
9. **Quantidades/unidades:** validar dimensionalmente kg×MZN/kg, H×MZN/H e HM×MZN/HM. Não somar kg com UN ou hora-pessoa com hora-máquina. Conversão só sob factor exacto com fonte/vigência. Unidades boas, perdas e WIP têm denominadores/estados próprios; não dividir por produção prevista para disfarçar produção real em falta.
10. **Reconciliação global:** para um perímetro completo, `fontes de custo consideradas = custo directo atribuído + CIF imputado + CIF não imputado + outras parcelas explicitamente fora dos objectos/por classificar`, sem incluir o pool como se fosse outra origem. Reconciliação parcial identifica a cobertura e componentes em falta; não reportar diferenças como zero por ausência de valor.
11. **Comandos/concorrência:** cálculo/aprovação idempotentes, com revisão/hash das dependências e bloqueios/uniquidades no servidor e base. Mesmo comando/conteúdo recupera IDs; outra chave não duplica imputação da origem. Falha em histórico/auditoria/outbox desfaz o conjunto. Mudança concorrente de fonte ou critério exige nova revisão, sem afectar cálculos históricos.
12. **Eventos:** guardar origem/versão, cálculo/critério, corte, actor e correlação; consumidor repetido não volta a imputar. Contabilidade futura usará acontecimentos elegíveis sob regras próprias; não activar consumidores de lançamentos nesta componente documental.
Reconciliação de gestão explica **custos**, não confere conta bancária, pagamento de salário ou recebimento. Não usar os estados de reconciliação simulada de recebimentos como aprovação do custeio.
## 8. Exemplo sintético — lote concluído
Empresa Industrial Exemplo / Unidade Fabril Exemplo / Centro Fabrico A / período Outubro de 2026 / lote `LOT-TEST-1` / Produto Exemplo, unidade **UN**. Moeda **MZN** explícita. Uma etapa, 10 UN boas verificadas, sem WIP, perdas ou retrabalho neste exemplo, pressupostos artificiais declarados. Todos os custos abaixo são entradas de teste, não valores observados da Tempo ou documentos de compra/salário reais.
| Origem sintética e componente realizada | Quantidade/base | Custo/taxa e versão | Destino | Custo atribuído |
| --- | ---: | --- | --- | ---: |
| `MAT-TEST-1`, material directo líquido | 20 KG | 50,00 MZN/KG, valorização v1 específica de teste | Lote | 1 000,00 |
| `TIME-TEST-1`, mão-de-obra directa verificada | 5 H | 100,00 MZN/H, taxa v1 de teste | Lote | 500,00 |
| `POOL-TEST-1`, CIF fixos elegíveis, origem total 600,00 | Lote usa 5 HM; capacidade normal sintética do período 10 HM | Critério v1: 600/10 = 60,00 MZN/HM | Lote | 300,00 |
| **Custo atribuído ao lote** | **10 UN boas** | 1 000 + 500 + 300 | — | **1 800,00** |
| **Custo unitário de produção de gestão do ensaio** | **1 800/10 UN** | Sem arredondamento necessário | — | **180,00 MZN/UN** |
Material bruto entregue no ensaio: 22 KG; devolvido não consumido: 2 KG; consumo líquido: 20 KG. Se a fonte monetária for o bruto valorizado, 22×50 − 2×50 = 1 000. Se a fonte já for o líquido de 20 KG, não descontar 2 KG novamente. Os documentos/acontecimentos de entrega/devolução são distintos; não se movimenta inventário real.
Reconciliação do conjunto: **600,00 = 300,00 imputados + 300,00 não imputados** por capacidade não utilizada sob a hipótese sintética. Não elevar taxa a 120/HM só para absorver 600. O destino contabilístico do não imputado não é executado ou presumido pela prova.
Reconciliação global do perímetro: **2 100,00 de origens líquidas = 1 800,00 no lote + 300,00 não imputados**. Origens são 1 000 material + 500 mão-de-obra + 600 CIF. Não somar 600 e a parcela 300 como duas origens, nem somar o custo 1 800 novamente às suas componentes.
### Previsto versus realizado
Mesmo produto e quantidade de 10 UN, plano v1 anterior à execução; não é orçamento comercial:
| Componente | Previsto e composição | Realizado | Desvio realizado − previsto |
| --- | ---: | ---: | ---: |
| Material | 18 KG × 48,00 = 864,00 | 20 KG × 50,00 = 1 000,00 | +136,00 |
| Mão-de-obra | 4 H × 100,00 = 400,00 | 5 H × 100,00 = 500,00 | +100,00 |
| CIF imputados | 4 HM × 60,00 = 240,00 | 5 HM × 60,00 = 300,00 | +60,00 |
| Custo do lote | **1 504,00** | **1 800,00** | **+296,00** |
| Custo por UN boa | **150,40** | **180,00** | **+29,60** |
Decomposição definida, sem resíduo oculto: material `(20−18)×48 = 96` de quantidade e `20×(50−48) = 40` de taxa, total 136. Mão-de-obra `(5−4)×100 = 100`, sem desvio da taxa. CIF `(5−4)×60 = 60`, **apenas efeito da base neste critério**, não prova de eficiência/capacidade do período completo. Soma 136+100+60=296. Se a quantidade obtida diferir da prevista, comparar primeiro volumes/perímetro compatíveis e declarar o efeito de volume; não chamar todo desvio a desperdício ou desfavorabilidade comprovada.
Não foi assumida venda, preço, IVA ou margem. Produzir dez unidades não demonstra que foram vendidas nem que 1 800 deva ser reconhecido como custo das vendas.
### Variante de custo em falta
Quantidade consumida 20 KG conhecida, mas taxa de valorização do material ausente: custo do material **pendente**, sem usar 50 ou preço de venda por omissão. Subtotal atribuído conhecido = 500 mão-de-obra + 300 CIF = **800,00**. Custo total, custo unitário completo e desvio total ficam **indisponíveis**. É possível mostrar 800/10 = 80 por UN **apenas como componente conhecida**, nunca como custo unitário completo.
Reconciliação das origens conhecidas: **1 100 = 800 atribuídos + 300 não imputados**, com componente material em falta explicitamente fora desta igualdade parcial. Quantidade conhecida não completa cobertura monetária. A variante do residual já existe no caso principal; nenhuma distribuição adicional é feita para esconder os 300.
## 9. Responsabilidades, confidencialidade e isolamento
Funções propostas, sem concessões ou pessoas designadas:
| Função | Acções propostas | Acesso delimitado |
| --- | --- | --- |
| Recolha operacional | Registar/verificar quantidades, tempos, produção e evidências do âmbito atribuído | Não vê taxas salariais individuais, custo/margem ou preços por consequência |
| Validação de custos | Conferir composição, origem, elegibilidade e versão das taxas/valores | Apenas unidades/centros e dados de origem necessários, com eventual agregação de remunerações |
| Aprovação de critérios | Validar conjunto, driver, denominador/capacidade, período, vigência, exclusões e residual | Não é poder de emissão, gestão de acessos ou alteração das origens operacionais |
| Preparação/análise | Calcular sob critérios aprovados, comparar previsto/realizado e consultar reconciliação | Consulta de custos separada de preços/receita/margem; não aprova a própria competência por nome |
| Consulta de margens | Ver indicador sob perímetro autorizado e completude explícita | Direito próprio; exige também autorização às fontes/dimensões usadas. Não extrair remunerações individuais |
Catálogo funcional proposto: `industrial_costs.operational_input`, `.view`, `.value`, `.propose_criteria`, `.approve_criteria`, `.calculate`, `.approve_result`, `.view_margin`. Separar consulta operacional, custos e margem; não herdar direitos de conta contabilística, administração, oficina ou emissão. A matriz de contas/âmbitos e eventual separação de autores serão decisões específicas antes de conceder direitos.
Centro empresarial reutilizável não abre dados de todas as unidades. Primeiro recorte não reparte custos entre unidades; extensão exige fontes e destinatários autorizados, período/driver e protecção dos resultados agregados. Negar também inferência por totais, APIs, eventos/exportações ou pesquisas de origem de outra empresa do mesmo tenant. Nova unidade/alteração de categoria não muda implicitamente permissões.
## 10. Matriz de reutilização com oficinas e SGF
| Elemento | Existe no SGF hoje | Proposto em oficinas | Proposta industrial / trabalho novo |
| --- | --- | --- | --- |
| Contexto e acesso | Identidade, empresa/unidade, sessão, concessões explícitas, auditoria | Aplicar às ordens/técnicos e custos | Reutilizar mecanismos; acrescentar objectos, centros e direitos próprios |
| Catálogos | Clientes/serviços empresariais e classificação contabilística versionada | Bem individual e recursos/materiais operacionais novos | Produto e centro de custo novos; não duplicar serviço nem confundir bem intervencionado com produto fabricado |
| Tempo | Aritmética exacta existente, sem módulo entregue de apontamentos | Intervalos/pausas, técnico, deslocação, unidade e validação propostos | Reutilizar **a mesma origem de tempo** quando for o mesmo acontecimento; acrescentar papel directo/indirecto, máquina e destino de custo |
| Material | Não há stock/compras/custeio operacional entregues | Necessidade, custódia/entrega, consumo observado e devolução propostos | Mesmo evento canónico/versão e propriedade; valorização e destino novos. Não criar novo consumo só porque passou ao cálculo |
| Documentos de origem | Histórico, B1, preparação/pré-visualização e factura sintética | Diagnóstico/proposta/aceite/relatório e vínculo a rascunho, ainda por construir | Documento de custo e ordem de produção próprios; nunca importar aceite da oficina como produção validada |
| Custos/preços | Valores exactos e classificação, sem motor de custo industrial | Separação custo/quantidade/preço e estado de custo proposta | Acrescentar planos de custo, conjuntos CIF, critérios, imputações, composição e desvios |
| Eventos/comandos | Padrões transaccionais/outbox/idempotência entregues | Novos tipos de recurso/ordem propostos | Reutilizar padrões e IDs, não alterar eventos históricos para fingir produção; não activar lançamentos |
| Reconciliação | Recebimentos apenas em simulação, entrega não presumida aceite | Conferência de recursos/custódia proposta | Reconciliação de gestão nova; sem reutilizar estado bancário/recebimento como validação de custos |
| Histórico | Fotografias/eventos imutáveis e versões | Versões de autorização, execução e facturabilidade propostas | Versões de origem, critério e cálculo, aprovação e substituição explícita |
Oficinas e indústria partilham contratos tipados de recurso/tempo/origem, **não a mesma máquina de estados**. Apenas o identificador comum de origem impede dupla captura; ligar uma ordem técnica a centro/lote exige intenção autorizada. Não desenvolver antecipadamente um motor universal ou duas tabelas sem ligação para o mesmo consumo.
## 11. Critérios de aceitação futuros
- Demonstrar o lote sintético: fontes 2 100, atribuído 1 800, residual 300, 10 UN boas, custo 180/UN e desvio 296 explicáveis pelos IDs e versões. Nenhuma venda/factura/recebimento ou lançamento criado.
- Material com custo desconhecido dá subtotal conhecido 800 e pendência; total/unitário completo e desvio indisponíveis. Valor zero validado continua distinto de ausência e de não aplicável.
- Origem repetida, mesma/diferente chave e consumidor repetido não duplicam consumo/atribuição. Bloquear parcela simultaneamente directa/indirecta ou em dois conjuntos/destinos para além do elegível.
- Driver zero/desconhecido, moeda ou unidade incompatível, capacidade fora da política, período/critério desactualizado e quantidade de boas desconhecida recusam cálculo/aprovação integral, sem redistribuição automática.
- Preservar exactidão e resíduo de representação separado de residual económico. Repartições reconciliam por origem/destino/corte; não esconder diferença por arredondamento ou conta transitória.
- Correcção de consumo/taxa/critério gera nova versão, invalida apenas resultados dependentes para uso actual e conserva aprovado anterior. Aprovação antiga, concorrência e SQL em massa não reescrevem história.
- Falha antes do commit deixa zero efeito parcial; resposta perdida recupera resultado; concorrência sobre parcela de origem e orçamento/conjunto não sobreimputa. Auditoria/evento/outbox na mesma transacção.
- Quantidades brutas/devoluções/líquidas não contadas duas vezes. Tempos de máquina/pessoa e produção boa/perda/retrabalho/WIP distintos. Ordem de oficina não se transforma implicitamente em lote.
- Negar URL/API/consumidor e exportação fora de empresa/unidade/centro autorizados; técnico/recolha não consulta custo salarial ou margem; revogação/contexto antigo têm efeito no pedido seguinte.
- Caso em curso/múltiplas etapas/produtos conjuntos não recebe custo unitário final por adaptação silenciosa do ensaio. Provas futuras exclusivamente sintéticas em `sgf_tempo_test`, com os bloqueios reais preservados.
## 12. Decisões realmente novas e faseamento coordenado
Decisões para aprovar antes de uma futura implementação, sem reabrir PGC-NIRF da Tempo, fiscalidade ou recebimentos:
1. **Destinatário e recorte:** identificar a primeira actividade/produto industrial e confirmar se uma ordem/lote de etapa única é representativa. Até lá, aceitar apenas o ensaio sintético por ordem, sem escolher método real.
2. **Perímetro e dados:** quais custos/fontes estão disponíveis, unidade e período, política de custos provisórios e quem valida. Não requerer capítulos dos livros para usar a base já fornecida; só pedir capítulo exacto se uma decisão realmente depender dele.
3. **Critério e representação:** aprovar conjunto/driver/capacidade e destino de residual do recorte; política de representação quando a divisão não for finita. Não impor método de arredondamento fiscal por preferência de interface.
4. **Responsáveis e acesso:** quem recolhe, valida custos, aprova critérios/resultados e consulta margens, em que empresa/unidade/centros. Não nomear automaticamente Osmane/Samira ou reutilizar concessões de oficinas.
Faseamento proposto:
| Fase | Entrega delimitada | Condição de passagem |
| --- | --- | --- |
| 1 — Oficinas | Fechar recorte e decisões de `WORKSHOP-ASSISTANCE-DRAFT-v1`; propor contratos mínimos de recurso/tempo/material observado e origem | Aprovação específica desse âmbito; não implementar indústria como dependência oculta |
| 2 — Base comum necessária | Implementar somente registos/IDs/versionamento e unidades efectivamente exigidos pelo primeiro percurso autorizado | Captura única e isolamento demonstrados; consumo técnico não finge stock. Se oficinas não avançar primeiro, esta base pode ser delimitada no ensaio industrial sem declarar oficina entregue |
| 3 — Ensaio industrial | **INDUSTRIAL-COST-SIM-v1**: um lote concluído/produto/unidade/período/MZN, material e horas sintéticos, um conjunto/critério CIF, previsto/realizado, custo unitário, residual e variante em falta | Autorização de código/testes separada; apenas demonstração isolada, sem integrações ou valorização legal activada |
| 4 — Aplicação real futura | Integrar origens efectivas e política empresarial/normativa validada | Novo âmbito para stock/compras/folha/activos, valorização/reconhecimento e acessos. Não decorre da aprovação do ensaio |
Produção em curso complexa, produtos conjuntos, subprodutos, perdas complexas, múltiplas etapas, ABC, repartição entre unidades, câmbio, custo das vendas e fecho são extensões explicitadas. As duas especificações partilham a fundação mas mantêm responsabilidades, acontecimentos e estados distintos.
**Menor incremento útil proposto para aprovação:** fase 3 limitada ao exemplo e respectivas guardas, reutilizando apenas a base comum estritamente necessária, após delimitar as fases anteriores. Esta etapa entrega só o desenho; não autoriza essa execução, não altera recebimentos ou conformidade e não atribui aceitação a nenhuma entrega existente.
## 13. Prioridade posterior: captar recursos na oficina primeiro
Em 27/10/2026, o utilizador propôs começar pela ordem de oficina, com materiais e tempos de origem identificada. O primeiro recorte coordenado passa a ser a proposta **`WORKSHOP-RESOURCE-RECORD-v1`**, delimitada na [secção 13 de oficinas](WORKSHOP_MANAGEMENT_SCOPE.md#13-primeiro-recorte-coordenado--ordem-e-recursos), antes da conversão comercial e do ensaio industrial. Esta orientação não autoriza implementar qualquer uma das componentes.
O custeio posterior ligará quantidade/tempo **já registados e verificados**, por ID e versão, a uma valorização e destino de custo próprios. Não voltará a registar consumo, tempo, devolução ou produção para conseguir calcular. O primeiro incremento não calcula custos nem exige que se escolha um método industrial. Uma ordem de oficina continua a ser ordem técnica; não nasce automaticamente uma ordem/lote de produção.
Assim, as fases 1/2 da secção 12 ficam inicialmente reduzidas à ordem, apontamentos e base de origem estritamente necessária; a proposta mais ampla `WORKSHOP-ASSISTANCE-DRAFT-v1` fica para evolução posterior. `INDUSTRIAL-COST-SIM-v1`, critérios CIF, custos e margens permanecem em especificação. Preservar bloqueios reais e a aceitação independente de `SGF-RECEIVABLES-SIM-v1`.
+56
View File
@@ -137,3 +137,59 @@ Consulta em 26/10/2026 limitada a software, numeração/comunicação e correcç
O utilizador confirmou **série nova**, Osmane prepara séries e pede correcções, Samira valida a configuração e o tratamento fiscal. Essas decisões estão encerradas, juntamente com Osmane preparar/emitir, propor regra temporal, Samira validar essa regra e navegador + PDF sem envio automático. Código/número inicial reais e novas concessões serão apresentados separadamente antes de aplicação.
Posteriormente autorizou a implementação técnica e os testes isolados, com emissão real bloqueada até resolver conformidade e com confirmações específicas antes de migração em desenvolvimento/concessões. Resultado, provas, delta e propostas concretas em [entrega técnica de teste](SERVICE_INVOICE_TEST_DELIVERY.md). Não reabre o cálculo aceite nem habilita emissão real.
## 11. Conformidade — cotejo dirigido de 27/10/2026
Esta secção actualiza a matriz de lacunas da secção 9, sem reabrir as decisões posteriores da secção 10. A migração e as oito concessões **já foram aplicadas por autorização específica**, conforme a entrega técnica. Não estão pendentes. Série real, contador e documentos continuam sem criação; o bloqueio real permanece. Esta revisão é documental, sem alteração de código, bases, acessos ou configuração.
### 11.1. Fontes efectivamente reutilizadas
Consulta limitada ao arquivo documental do InvesteMoz em `docs/investmoz/simulations/source-archive`, em modo de leitura. Nenhuma base, configuração privada, código ou teste do portal foi consultado/executado. Reutilizados a matriz de cálculo do SGF e o cotejo anterior de alterações, sem repetir investigação de taxa/base ou os ensaios aceites.
| Referência | Exemplar e disposição conferida nesta revisão | Identidade / limite |
| --- | --- | --- |
| CIVA republicado pela Lei 13/2016 | `Lei-13-2016-CIVA-BR-156-10-suplemento.pdf`, páginas PDF 17–18, BR 1426-(275)–(276), art. 27; página PDF 21, BR 1426-(279), arts. 50/51. Leitura visual do exemplar digitalizado | SHA-256 `B90DC7C14ECEA2E378C4A62E8E5A47D54A5644C457BAEB8B8053B92FD2A5710B`. Base histórica, lida com alterações posteriores; não é, isoladamente, texto consolidado de 2026 |
| RIVA, Decreto 7/2008 | `BR-16-I-Serie-2008-04-16.pdf`, página PDF 4, arts. 21 e 24(2) | SHA-256 `FC373FF5637EDF75EC6A9595DEC8763A8503D4EE786E3B7D8F1C742EAE9D80EE`. Art. 21 sujeito à verificação das alterações posteriores |
| Rectificação de 2017 | `BR-89-I-Serie-2017-06-08.pdf`, aviso da Assembleia da República | Corrige art. 6, art. 14 e anexos; não fornece procedimento de autorização do SGF nem de correcção de facturas |
| Lei 10/2025 | `BR-248-I-Suplemento-1-2025.pdf`, página PDF 14, art. 27(10); página 15, entrada em vigor em 01/01/2026, já cotejada | SHA-256 `CCF2A8DECEA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251`. Na alteração do art. 27, os n.os 1–9 aparecem mantidos por remissão; o n.º 10 tem nova redacção |
| Informação oficial actual | [AT — comunicados](https://www.at.gov.mz/informacoes/comunicado-de-imprensa) e [Governo — alterações ao RIVA, notícia de 30/09/2026](https://portaldogoverno.gov.mz/governo-introduz-alteracoes-nos-regulamentos-fiscais-para-reforcar-eficiencia-tributaria/) | Consulta em 27/10/2026. Metadados de lista e anúncio não substituem anexo, despacho de autorização ou diploma publicado |
As alterações já cotejadas da Lei 22/2022 e da Lei 3/2025 não fornecem novo procedimento para estes quatro pontos. O art. 51 não integra a lista de artigos alterados pela Lei 10/2025. Isto delimita o cotejo efectuado, sem afirmar consolidação exaustiva de todos os diplomas até hoje.
### 11.2. Matriz actualizada: demonstrado versus ainda por demonstrar
| Matéria | O que ficou fundamentado | O que continua a bloquear prontidão real |
| --- | --- | --- |
| **Programa de facturação** | CIVA art. 27(9): conteúdo processado electronicamente exige programa específico de facturação autorizado pela AT. O n.º 9 está preservado na alteração conferida de 2025. A autorização de tipografias do art. 27(7)/RIVA 21(4–5) é outra matéria | Procedimento oficial vigente para requerer/verificar essa autorização e prova específica de que o SGF, na versão/instalação/âmbito pretendidos, a satisfaz. Não existe nos materiais conferidos autorização do SGF. Aprovação local, PDF, hash e testes não a substituem |
| **Série e numeração** | CIVA art. 27(5/7): documento datado e numerado sequencialmente, com distinção entre processamento electrónico e impressão tipográfica. RIVA 24(2): uma ou mais séries referenciadas; conservar duplicados em ordem e todos os exemplares anulados/inutilizados, identificando substitutos quando existam | Confirmar condições administrativas vigentes para a série nova e sua relação com autorização/comunicação. Código `SGF-FT-A` e primeiro número `1` continuam propostas, sem aprovação, reserva ou configuração. A base consultada não impõe por si um reinício anual nem prova que o código proposto está autorizado |
| **Submissão à AT** | CIVA art. 27(10), redacção da Lei 10/2025, efectiva desde 01/01/2026: submissão das facturas/documentos equivalentes emitidos por operação, com execução remetida para regulamentação. Não confundir esta obrigação com declaração mensal ou autorização do programa | Texto executório aplicável ao caso: canal, formato, conteúdo, prazo, identificação da série/documento, comprovativo, rejeição e contingência. Determinar com fonte se a aceitação é anterior à emissão ou se a obrigação é posterior. Não se demonstrou um protocolo/API ou dispensa; também não se demonstrou que inexistam |
| **Correcção e regularização** | CIVA art. 51(1–5) e RIVA 24(2) fornecem regras concretas, descritas abaixo. A exigência de preservar original e ligações tem suporte documental; pedido interno não altera imposto | Procedimento operacional fiscalmente validado, documento adequado a cada motivo, elementos/referências, prova do destinatário quando necessária e tratamento declarativo. O SGF apenas regista pedidos; não produz ainda a correcção fiscal. Antes de emitir, deve existir uma via executável conforme, interna ou externa identificada, sem a presumir |
**Limite da actualização regulamentar:** a notícia oficial enumera alterações aos arts. 3, 7, 8, 15, 16-A, 18, 21, 23, 28, 36 e 37 do RIVA. O articulado publicado, número do diploma e início de eficácia dessa alteração não foram recuperados nesta consulta. Não afirmar que não foram publicados. Em particular, não configurar o prazo histórico do art. 21 como se estivesse integralmente confirmado para 2026. A notícia contém referências que não substituem o BR; não resolver divergências de datas por suposição.
**Limite da lista de software:** o índice da AT identifica uma lista provisória de software de facturação, com referência nominal a 01/02/2026. A consulta da página e dos seus metadados públicos não disponibilizou anexo verificável nem certificado do SGF. Datas de listagem/publicação não devem ser tratadas como vigência de autorização. Não se adopta a afirmação comercial de que todos os certificados estariam pendentes, nem se conclui dispensa de comunicação ou de validação prévia a partir de páginas de fornecedores.
### 11.3. Procedimento de correcção: base já encontrada
Referências abaixo correspondem ao art. 51 do exemplar identificado, com os limites de actualização acima. Não se configuram prazos automaticamente nesta etapa.
- **Alteração posterior do valor tributável ou imposto:** n.º 1 remete novamente para as disposições dos arts. 27 e seguintes. Não autoriza editar a fotografia original.
- **Anulação/redução após registo**, por invalidade, resolução, rescisão, redução contratual, devolução ou abatimentos/descontos: n.º 2 prevê dedução pelo fornecedor até ao fim do período de imposto seguinte àquele em que ocorreram as circunstâncias. Não confundir com simples erro de imposto na factura.
- **Inexactidão na factura:** n.º 3 distingue imposto liquidado a menos — rectificação obrigatória, sem penalidade até ao fim do período de imposto seguinte ao da factura — de imposto a mais, cuja rectificação é facultativa no prazo de um ano. O prazo de um ano não se generaliza a todos os motivos de correcção.
- **Destinatário sujeito passivo:** n.º 4 prevê correcção da dedução nas condições e prazo aí definidos, após recepção do documento rectificativo; o processo deve identificar se esse ramo é aplicável.
- **Redução a favor do fornecedor:** n.º 5 exige prova de que o adquirente tomou conhecimento da rectificação ou de que foi reembolsado; sem ela a dedução é indevida. Guardar documento e evidência ligados ao original, com datas e autor. Não presumir que um clique interno do emitente prova conhecimento do cliente.
- **Arquivo:** RIVA 24(2) impede tratar documento anulado/inutilizado como registo a apagar; CIVA art. 50 prevê conservação dos livros, registos e documentação de suporte durante os dez anos civis subsequentes. Conservação técnica e procedimento fiscal devem ser compatíveis, sem renumerar ou reescrever originais.
Os n.os 6–7 tratam erros materiais/de cálculo em registos e declarações, com condições próprias. Não são um atalho para corrigir uma factura emitida por edição directa ou para produzir lançamentos neste incremento. As regras de devolução de mercadorias do art. 27(3/6) também não justificam automaticamente um documento de correcção de alojamento/capacidade.
**Circuito operacional a fechar, sob as responsabilidades já aprovadas:** Osmane identifica original/motivo/montantes e pede a correcção; Samira valida o ramo fiscal, fundamento, documento, prazo e evidências; a execução conserva original, identidade da rectificação e vínculo, sem duplicar operação. Se depender de outro sistema autorizado, identificar qual, como evitar duplicação/numeração concorrente e como conservar o comprovativo. Não foi escolhido fornecedor nem autorizada integração. Falta validar esta via concreta; não falta voltar a escolher os responsáveis.
### 11.4. Lista reduzida de provas e próximo passo
1. **Regulamentação e autorização:** obter o diploma publicado das alterações anunciadas ao RIVA e instrução/despacho oficial aplicável à autorização de programas; depois, evidência específica de autorização do SGF e respectivos limites. Um modelo de factura e memória técnica poderão integrar um processo, mas não são aqui declarados requisitos vigentes sem fonte oficial.
2. **Série/comunicação:** obter a disposição ou orientação formal que determine condições da série e execução do art. 27(10), incluindo eventual transição/contingência. Só então apresentar a configuração exacta da série nova para aprovação; não pedir novamente a decisão de criar série nova.
3. **Correcção executável:** Samira valida a aplicação dos ramos acima ao serviço e a via concreta de emissão/entrega/regularização, com evidências e prazos. Não basta aprovar um pedido sem efeito monetário.
São provas normativas/operacionais e autorização externa específica, não escolhas a resolver com novas taxas, ensaios aritméticos ou preferências visuais. Se os textos/actos não estiverem disponíveis nos materiais de trabalho, solicitar à AT/DAF competente a referência oficial e resposta documentada sobre estes três pontos. **Nenhum contacto foi enviado.** Não há fundamento para desbloquear emissão enquanto estas provas faltarem.
Próximo incremento apenas após estas provas: confrontar requisitos concretos com o percurso técnico aceite e propor somente o delta necessário. Até lá, preservar navegador/PDF de teste, cálculo condicionado, B1, aprovações contabilísticas e guarda de emissão real. Não foi executado novo teste, migração, concessão, série, documento, recebimento, lançamento, publicação ou checkpoint nesta revisão documental.
+21
View File
@@ -103,3 +103,24 @@ A activação real de emissão exigirá novo âmbito explícito e alteração re
Autorizado registo e checkpoint Git exclusivamente local, sem credenciais ou dados reais. Incluídos código, testes, migração ainda não aplicada em desenvolvimento, especificação, relatório e provas sintéticas/agregadas; o PDF de demonstração contém só identidades sintéticas. Excluídos ficheiros de ambiente privados, manifestos, bases, logs, imagens reais e ficha nominativa local. Não se repetiram testes ou validações para este registo; resultados mantidos: 53 testes / 924 asserções e compilação aprovada.
A aceitação **não aplica a migração `060000`, as oito concessões ou a série real**, não resolve as provas de conformidade nem as pendências temporais do caso real, e não autoriza habilitar emissão real, recebimentos, lançamentos ou publicação. O bloqueio no servidor permanece. As próximas operações continuam sujeitas às confirmações específicas já delimitadas.
## Aplicação da migração e das concessões especificamente autorizada
**27/10/2026, Africa/Johannesburg.** Posteriormente à aceitação, o utilizador autorizou expressamente: aplicar a migração e as oito concessões exactamente propostas, verificar preservação e manter emissão real bloqueada, sem criar/configurar série real, emitir ou publicar. Esta secção substitui o estado pendente das secções históricas acima apenas quanto à migração/concessões.
1. Confirmados directório SGF, contas existentes #1/#2, empresa #1 e unidade #1; nenhuma conta ou pertença criada. Leitura de `migrate:status` confirmou só a migração `060000` pendente.
2. Antes da aplicação, `scripts/invoice-dev-preservation.php --before` recolheu contagens e impressões digitais de **31 tabelas preexistentes**, incluindo `sf_*`, B1, cálculos, catálogo, identidades, acessos e auditoria. Só os agregados/hashes ficaram em armazenamento local ignorado; nenhum conteúdo de registo foi apresentado.
3. `scripts/sgf.ps1 migrate` aplicou exclusivamente `2026_10_26_060000_create_test_invoice_issuance.php` na base **`sgf_tempo_dev`**, com proprietário separado. A verificação imediatamente posterior confirmou dados/acessos intactos e oito tabelas novas vazias. [Prova da migração](proofs/invoice-dev-preservation.json).
4. `scripts/invoice-access.php 1 2 1 1 --apply` aplicou exactamente **oito concessões**, sem novos direitos fora da matriz aprovada. Auditoria: oito entradas `service_invoice.initial_grant`.
5. `scripts/invoice-dev-preservation.php --after-grants` confirmou a matriz exacta e a preservação dos registos, acessos e auditoria anteriores; o único delta de acessos são as oito concessões autorizadas. [Prova após concessões](proofs/invoice-dev-preservation-grants.json).
6. O ficheiro que contém a guarda de emissão ficou com hash inalterado. Verificadas recusa **403** nas entradas `guard`, `issue` e `propose` no ambiente normal, antes de qualquer operação documental. A consulta normal com os novos direitos confirma `real_blocked=true`, `test_enabled=false`, listas vazias.
**Estado final:** `si_proposals`, `si_decisions`, `si_counters`, `si_invoices`, `si_events`, `si_outbox`, `si_commands` e `si_correction_requests` com zero registos. Sem série real, contador, reserva de número, documento ou pedido de correcção criado. A instalação e concessões não habilitam emissão, configuração real ou comunicação externa. O perfil/cálculo condicionado anterior e as suas pendências temporais permanecem preservados.
Não foram repetidos testes destrutivos aceites para aplicar esta migração já testada. Não houve alteração de código funcional, ferramentas, publicação ou novo checkpoint. Os dois scripts administrativos novos documentam/reproduzem a verificação; repetir aplicação de concessões exige respeitar o âmbito confirmado, sem ampliar a matriz.
Para consultar o bloqueio: actualizar a página ou voltar a entrar, seleccionar empresa/unidade autorizadas e **Facturas de teste**. O ecrã normal deve apresentar **Emissão real indisponível**, mesmo para a conta com o novo direito de emissão. As provas de conformidade e a configuração da série real continuam separadas e por resolver/aplicar.
### Actualização documental de conformidade — 27/10/2026
O cotejo dirigido e a lista reduzida de provas estão na [secção 11 do âmbito de emissão](SERVICE_INVOICE_ISSUANCE_SCOPE.md#11-conformidade--cotejo-dirigido-de-27102026). Foram conferidos nos exemplares já arquivados o requisito de programa autorizado pela AT (CIVA 27(9)), a numeração/séries e conservação (CIVA 27; RIVA 24(2)), a obrigação de submissão remetida para regulamentação (Lei 10/2025, CIVA 27(10)) e os ramos de rectificação (CIVA 51). Isto reduz lacunas de fundamento; não fornece autorização do SGF, o protocolo executório vigente ou uma via concreta validada de correcção. A emissão real continua bloqueada e nenhuma série foi configurada. Sem nova alteração funcional, base, concessão ou repetição dos testes aceites.
+108
View File
@@ -0,0 +1,108 @@
# SGF-RECEIVABLES-SIM-v1 — entrega técnica isolada
27/10/2026, Africa/Johannesburg. Implementação do âmbito aprovado na secção 12 de [SERVICE_RECEIVABLES_SCOPE.md](SERVICE_RECEIVABLES_SCOPE.md). **Pronto para revisão do utilizador; não constitui aceitação.** Preservados `4276aca` e alterações posteriores. Sem novo checkpoint ou publicação.
## Funcional
- Obrigação explícita sobre ID exacto de documento sintético `si_invoices`, com fotografia e moeda preservadas; documento existente não cria dívida automaticamente. Rascunho/B1 não são origens aceites.
- Propostas imutáveis de obrigação, recebimento, observação e correcção. Cada decisão exige versão exacta e revisão actual da obrigação; confirmação/aprovação por identidade distinta do proponente. Propostas não são editadas: mudança exige nova proposta/revisão, conservando a anterior. Versões não suportadas são recusadas.
- Recebimento confirmado não afecta automaticamente o documento nem comprova reconciliação. Afectação exige comando próprio, saldo disponível e saldo do documento suficientes, na mesma empresa/unidade/cliente/moeda.
- Montantes exactos MZN, sem float ou truncagem: quantias precisam de ser exactamente representáveis a duas casas e respeitar o limite técnico. A base de dados recusa fracções adicionais antes de qualquer arredondamento de coluna. O motor fiscal existente não foi alterado.
- Excesso integral preservado por afectar. Sobreafectação recusada por inteiro; sem crédito, receita, reembolso ou adiantamento automático.
- Saldo, recebido, afectado e disponível derivados do histórico. Vencimento desconhecido não produz atraso; vencido apenas no dia posterior ao vencimento conhecido. Liquidação e reconciliação são dimensões distintas.
- Reconciliação 1:1 exclusivamente simulada, exacta, com observações independentes e identidades explícitas. Divergência permanece registada, sem tolerância; observação não cria recebimento. Reutilização indevida de observação/recebimento conferidos é recusada.
- Reversões integrais por proposta e aprovação; original preservado. Reverter recebimento exige antes libertar afectações e conferência activas, por operações explícitas. Redução sintética identifica documento `TEST-REDUCTION-...`, linha 1 e original. Redução acima do saldo é recusada; não modifica factura ou pedido fiscal de correcção.
- Comprovativo interno apenas no navegador, com origem, data de corte, revisão, valores e efeitos. Avisos permanentes **TESTE — SEM VALIDADE FISCAL** e **SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO**. Não cria recibo fiscal nem novo PDF de recebimento.
## Protecção e persistência
`ServiceReceivables::guard()` recusa execução fora de `testing`/`browser-testing` e executa `TestingDatabaseGuard`: destino efectivo `sgf_tempo_test`, cluster 127.0.0.1:55432, utilizador de testes próprio. A consulta normal devolve bloqueio e listas vazias sem depender das novas tabelas. Não existe flag de activação real.
Todos os INSERT nas seis tabelas novas têm também trigger que recusa uma base diferente de `sgf_tempo_test` ou porta diferente de 55432. Relações de documento, cliente, unidade e moeda são validadas na base; UPDATE/DELETE em massa recusados nos registos de história. Guardas de agregado e revisão serializam escritores por empresa, incluindo INSERT directo em SQL. Não se declara RLS ou autenticação de utilizador final por SQL: a autorização por sessão/contexto/direitos é revalidada no servidor, e a ligação administrativa continua uma fronteira distinta.
Comando, efeito, auditoria, evento e outbox ficam na mesma transacção. Chave por empresa/unidade/actor/acção, hash canónico e unicidades de acontecimento protegem repetições e nova chave sobre a mesma origem. Replay exige direito actual; não reaplica uma afectação entretanto revertida. Consumidor interno `simulation_integrity_v1` marca entrega idempotente em tabela separada e verifica integridade; não contabiliza nem comunica com bancos.
A consulta bloqueia brevemente a linha da empresa para compor uma vista coerente. Todos os escritores usam a mesma ordem de bloqueio. É uma escolha limitada ao incremento local, com serialização ampla; não é promessa de escala para produção. Formulário antigo recebe conflito, exige actualização explícita e não grava sobre a revisão nova.
## Provas e resultados
Ver [resultado do navegador](proofs/receivables-browser-results.json), [imagem](proofs/receivables-browser.png), [concorrência](proofs/receivables-concurrency.json) e [resultado agregado dos testes](proofs/receivables-tests.json).
Prova principal efectuada com login real no Edge, sessão/cache isolados, CSRF activo, `http://127.0.0.1:8001` e exclusivamente `sgf_tempo_test`. Não se usou `actingAs` para demonstrar entrada. As duas identidades sintéticas têm as funções equivalentes à matriz aprovada, sem poderes derivados dos nomes.
| Momento observado no navegador | Recebido | Afectado | Por afectar | Saldo do documento |
| --- | ---: | ---: | ---: | ---: |
| Obrigação confirmada | 0,00 | 0,00 | 0,00 | 1 000,00 |
| Dois recebimentos confirmados, antes das afectações | 1 000,00 | 0,00 | 1 000,00 | 1 000,00 |
| Afectação de 400 | 1 000,00 | 400,00 | 600,00 | 600,00 |
| Afectação adicional de 600, antes da conferência | 1 000,00 | 1 000,00 | 0,00 | 0,00 |
| Duas observações conferidas em simulação | 1 000,00 | 1 000,00 | 0,00 | 0,00 |
O documento sintético de 1 000,00 foi produzido pelo percurso de emissão já aceite, parametrizando apenas a fixture: preço sem imposto 862,07 + IVA de ensaio documental 137,93 = 1 000,00. Não foi atribuída taxa zero nem alterado o motor fiscal. Isso prepara a origem do ensaio; não valida tributação real.
Os testes abrangem ainda: 400+500 após redução sintética de 100; excesso de 100 por afectar; divergência de 0,01; reversões dependentes e repetidas; redução depois de liquidação; observação repetida/reutilizada; escala/limites, datas/vencimento, evidência ausente, origem e IDs manipulados, separação de autores, consulta sem alteração, revogação, contexto antigo, empresa irmã, unidade não autorizada e SQL em massa. Falhas injectadas no outbox desfazem também o efeito financeiro; resposta perdida recupera o mesmo resultado. Falha e repetição da entrega interna não duplicam efeitos.
Seis cenários com dois processos realmente sobrepostos: mesma confirmação produz um efeito e um replay; 400+700 disputando saldo de 1 000; duas afectações disputando o disponível de um recebimento; escritores SQL concorrentes; reversão versus conferência; duas reduções sobre a mesma revisão. Conflitos não repartem valores silenciosamente e não produzem saldo negativo. A prova SQL insere deliberadamente efeitos sintéticos directamente, apenas para verificar as guardas; não representa um comando autorizado de aplicação ou fluxo com auditoria completo.
Resultado final: **39 testes, 711 asserções, zero falhas/erros**; compilação **Vite 8.3.5 aprovada**, sem alteração de dependências. Filtro relevante: `ServiceReceivablesTest|ServiceInvoiceTest|InternalConfirmationTest|DatabaseSafetyTest`. A instância do navegador foi parada antes da regressão final; essa regressão recria apenas a base de testes. As imagens/resultados permanecem; os IDs da demonstração não são garantidos numa repetição. A revisão final reforçou a resolução do cliente pela versão imutável em `a_history` e fixou hash/referência de origem nos novos registos; não depende do rascunho actual. Proposta recusada permanece sem obrigação aberta e sem efeito de atraso.
## Percurso exacto para repetir no navegador
Com autorização de demonstração sintética, preparar a base de testes pelo comando de testes abaixo e garantir que não existe outra prova activa. Os preparadores recusam fixtures preenchidas: não apagam dados para conseguir continuar. Se a porta 8001 estiver ocupada, parar e identificar; nunca terminar processos de outro projecto.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test '--filter=ServiceReceivablesTest|ServiceInvoiceTest|InternalConfirmationTest|DatabaseSafetyTest'
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 start
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/receivables-fixture.php --env=testing
```
Credenciais aleatórias apenas no ficheiro local protegido já usado pelo preparador, nunca no relatório/chat. Não usar contas reais na instância de prova.
1. Entrar como gestor sintético, seleccionar **Empresa Horizonte — Demonstração → Unidade Central — Demonstração → Recebimentos simulados**.
2. Escolher documento exacto `TEST-SERIE-ISOLADA/1`, montante 1 000,00; **Propor obrigação**, com vencimento vazio para provar a pendência.
3. Sair e entrar como validador sintético; seleccionar mesmo contexto; **Confirmar proposta** da obrigação. O proponente não pode confirmar a sua proposta.
4. Voltar ao gestor e propor recebimentos **400.00** e **600.00**, cada um com data declarada não futura, ID de acontecimento diferente e evidência explicitamente artificial. Voltar ao validador e confirmar cada proposta. O saldo continua 1 000 até haver afectação.
5. Voltar ao gestor e **Afectar explicitamente** 400 e 600 nos respectivos recebimentos. Conferir saldo zero, disponível zero e ambos ainda sem reconciliação simulada.
6. Preparar duas observações artificiais, escolhendo o recebimento exacto em cada uma, valores 400/600, datas, IDs de observação distintos e evidências de fonte simulada.
7. Voltar ao validador e **Conferir correspondência exacta** em cada observação. Abrir **Consultar comprovativo interno** e verificar referência documental, montantes, revisão, autores e avisos. Nenhuma descarga de recibo/PDF novo.
8. Para os cenários concorrentes independentes: `sgf.ps1 php scripts/receivables-concurrency.php --env=testing`; para verificar/guardar o agregado do caso principal e entregar eventos internamente: `sgf.ps1 php scripts/receivables-proof.php --env=testing`. Não executar concorrência contra a obrigação principal.
9. Terminar com `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 stop` antes de voltar a executar testes destrutivos. A aplicação normal conserva a sua ligação.
## Delta aditivo — NÃO aplicado em desenvolvimento
Migração **`2026_10_27_070000_create_simulated_receivables.php`**, com SQL em `database/sql/receivables.sql`. `migrate:status` consultado em `sgf_tempo_dev` confirmou **Pending**. Foi aplicada/recriada apenas pela suite em `sgf_tempo_test`.
Cria seis tabelas:
| Tabela | Finalidade |
| --- | --- |
| `sr_records` | Propostas/documentos sintéticos e referências imutáveis de obrigação, recebimento, observação e correcção |
| `sr_effects` | Decisões, afectações, reconciliações, reduções e compensações imutáveis |
| `sr_commands` | Idempotência e resultado recuperável |
| `sr_events` | Evento versionado por comando, com origem/correlação |
| `sr_outbox` | Captura durável apenas de simulação |
| `sr_deliveries` | Marca imutável de entrega do consumidor interno idempotente |
Acrescenta FKs, índices de unicidade, CHECKs, funções/triggers próprios para relações, precisão, saldos, revisões, separação de autores e imutabilidade. Amplia o CHECK de `permission_grants` com sete nomes de acção exclusivamente de unidade; **não atribui direitos** nem altera alternativas anteriores. Não modifica documentos/B1/cálculos/catálogos ou as tabelas `si_*`. `down()` recusa destruição de história; sem reset/fresh ou rollback destrutivo em desenvolvimento.
**Proposta pendente, a confirmar antes de executar:** aplicar exclusivamente este delta em `sgf_tempo_dev`, preservando dados/acessos, com comparação de integridade antes/depois. Não executado. Mesmo instalada em desenvolvimento, a guarda de aplicação e os triggers impedem INSERT operacional deste ensaio nessa base. Não é proposta de activação real.
## Oito concessões — proposta separada, NÃO aplicada
Contas/âmbito conferidos por leitura através de `scripts/receivables-grants-preview.php`, que não tem modo de aplicação. Tenant #1, **Tempo Lda, empresa #1 / SEGI, unidade #1**, sem extensão a outra unidade. Todos os oito direitos estavam ausentes nessa conferência.
| Conta existente | Novas permissões propostas, prefixo `service_receivables.` | Quantidade |
| --- | --- | ---: |
| #1 — Osmane Nala, nala@tempo.co.mz | `view`, `prepare`, `allocate`, `correction_request` | 4 |
| #2 — Samira Nal, onala@me.com | `view`, `confirm`, `reconcile`, `correction_approve` | 4 |
Exigem confirmação específica antes da aplicação, separada da migração. Sem gestão destes acessos, herança, emissão, banco, reconhecimento ou fecho. Não foram aplicadas às contas reais; apenas equivalentes sintéticos receberam direitos na fixture de testes. Uma eventual concessão local não permite executar recebimentos reais nem contornar o bloqueio.
## Ficheiros e limites
Código novo: `app/Modules/Services/ServiceReceivables.php`, `Http/ServiceReceivablesController.php`, `resources/js/ServiceReceivablesWorkspace.jsx`; integração em `routes/web.php` e `resources/js/app.jsx`. Migração/SQL acima. Testes e fixtures: `tests/Feature/ServiceReceivablesTest.php`, `tests/Support/ReceivablesFixture.php`; `InvoiceFixture` ganhou parâmetro de preço com valor anterior preservado por omissão. Scripts: `receivables-fixture.php`, `receivables-worker.php`, `receivables-concurrency.php`, `receivables-proof.php`, `receivables-grants-preview.php`. Documentação e provas referidas neste relatório.
Sem dependências/ferramentas novas. Não se modificou o motor exacto, `ServiceInvoicing::guard()`, fotografias B1 ou aprovações existentes. Não se repetiu investigação fiscal do InvesteMoz nem se lhe acedeu neste incremento. Nenhum dado de demonstração entrou na base normal; migração e direitos novos permanecem pendentes. Não foi produzido recibo fiscal, PDF de recebimento, movimento de dinheiro, lançamento, fecho ou comunicação externa.
Limites deliberados: uma moeda MZN, uma linha e obrigação por documento, um documento por recebimento, conferência integral 1:1, reversões integrais, sem edição de propostas/efeitos confirmados, sem câmbio/adiantamentos/retenções/descontos/reembolso. Uma operação que precise de extensão é recusada; não se finge que cabe no caso simples. Conformidade e emissão/configuração reais continuam bloqueadas. Entrega técnica não significa aceitação do utilizador ou autorização do incremento seguinte.
+208
View File
@@ -0,0 +1,208 @@
# Serviços — contas a receber, recebimentos parciais e reconciliação simulada
27/10/2026, Africa/Johannesburg. Versão 0.2. **Âmbito aprovado pelo utilizador; ver secção 12. Sem aplicação de migrações, concessões ou operações por este registo.**
## 1. Base preservada e incremento mínimo
Directório confirmado: `C:\laragon\www\sgf-tempo`. Base Git: `4276aca`, entrega técnica aceite de documento de teste no navegador e PDF. Preservar integralmente alterações posteriores, incluindo a migração `060000` e as oito concessões já aplicadas, respectivas provas e a revisão documental de conformidade. Não fazer checkout/reset desse checkpoint para iniciar este trabalho.
Fontes reutilizadas, sem novo cotejo fiscal do InvesteMoz:
- [Percurso de serviços](SERVICES_WORKFLOW_SPEC.md): cenário 1 000,00 → 400,00 + 600,00, variante redutora e reconciliação simulada. Esta proposta recorta parte de C; não autoriza o fecho ou a contabilidade previstos naquele roteiro.
- [Matriz documental](BILLING_DOCUMENTS_FIELD_SPEC.md): identidade exacta, recebimento, afectação e recibo distintos; FT 10/2026 não se confunde com FT 11/2026 pelo valor.
- [Delimitação B](SERVICES_B_SCOPE.md), [entrega B1](SERVICES_B1_DELIVERY.md), preparação documental, pré-visualização e cálculo aceites: não converter confirmação interna ou cálculo em dívida exigível.
- [Entrega técnica de emissão](SERVICE_INVOICE_TEST_DELIVERY.md), incluindo aplicação posterior da migração/concessões, e [conformidade, secção 11](SERVICE_INVOICE_ISSUANCE_SCOPE.md#11-conformidade--cotejo-dirigido-de-27102026): emissão/configuração real permanecem bloqueadas. Não repetir provas aceites ou reabrir responsáveis/série/fiscalidade.
Leitura dirigida do código actual: `ServiceInvoicing` já fixa documento, fotografia, PDF, identidade, contador, auditoria, evento/outbox e comando idempotente, exclusivamente no ambiente isolado seguro. `si_correction_requests` guarda pedidos sem efeito monetário. `DraftValues`/Brick Math fornecem aritmética decimal exacta; o contexto e as permissões vêm da fundação. Não foram localizados módulos/rotas/migrações operacionais de contas a receber, recebimentos, afectações ou reconciliação na pesquisa dirigida de `app/Modules`, `routes` e `database/migrations`.
| Já existe e deve ser reutilizado | Falta neste incremento proposto |
| --- | --- |
| Cliente/serviço da empresa; documentos da unidade; sessão, CSRF, contexto e permissões explícitas | Obrigação operacional de teste, com abertura explícita e saldo derivado |
| Documento sintético imutável `si_invoices`, identidade e fotografia preservadas | Recebimento proposto/confirmado, afectação explícita e reversões rastreáveis |
| Aritmética exacta, auditoria e padrões transaccionais/idempotentes | Invariantes de disponibilidade, saldo e concorrência entre recebimentos/afectações |
| Eventos/outbox de A, B1 e emissão, com semânticas próprias | Eventos específicos deste percurso; observações e conferência exclusivamente simuladas |
| Pedido de correcção documental sem efeito | Documento redutor sintético independente e aplicação explícita à obrigação, sem correcção fiscal real |
Não reconstruir acontecimentos a partir do estado actual dos cadastros nem consumir eventos B1 como dívida. Reutilizar mecanismos, sem mudar a semântica dos eventos existentes ou activar consumidores contabilísticos.
## 2. Recorte e linguagem da demonstração
Uma empresa sintética, uma unidade, um cliente, um documento de origem sintético e **MZN explicitamente seleccionado no cenário**. Montante documental de teste: 1 000,00, já fixado na fotografia; não recalcular IVA nem deduzir taxa zero desse montante. Não utilizar Tempo Lda/SEGI, Aurora, contas reais ou o cálculo condicionado real como fixtures.
Só admitir como origem uma identidade exacta de documento de teste elegível produzido pelo percurso já aceite. Uma fixture poderá prepará-lo pelos mecanismos existentes; não inserir uma factura fingindo contornar prontidão/guarda. Uma obrigação por documento neste recorte, sem prestações parceladas, múltiplos documentos ou repartição entre unidades/clientes. Integração futura de facturas reais exige âmbito próprio.
Avisos permanentes nas listas, detalhes, observações e comprovativo interno: **«SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO»** e **«TESTE — SEM VALIDADE FISCAL»**. Obrigações e recebimentos de teste não representam dívida ou pagamento reais. Não criar recibo fiscal, factura-recibo, série real ou nova numeração fiscal. Não acrescentar PDF neste incremento: o PDF de origem existente continua preservado.
Excluir câmbio, múltiplas moedas, retenções, descontos financeiros, adiantamentos, afectações entre clientes/unidades, devoluções de dinheiro, taxas bancárias, importações bancárias e conciliação agregada de vários recebimentos. Também fora: lançamentos, reconhecimento, apuramento fiscal, fecho e publicação. Um excesso é uma excepção ligada a esta obrigação existente, não autorização para gerir adiantamentos.
## 3. Entidades, ligações e campos
Todas as entidades operacionais levam ID estável, tenant/empresa/unidade imutáveis, cliente, moeda explícita quando monetárias, marcador sintético controlado pelo servidor, autor, instante de registo UTC e zona/data civil relevante. Contexto validado no servidor; IDs enviados pelo navegador não autorizam relações. Cliente é reutilizado do cadastro empresarial, sem cópia por unidade; o snapshot de origem conserva a identidade histórica.
| Entidade | Ligações e campos específicos mínimos | Efeito próprio |
| --- | --- | --- |
| Documento de origem | ID `si_invoices`; empresa/unidade/cliente; tipo, série, número; versão/hash da fotografia; montante e moeda fixados; referência legível de teste | Fonte documental. Existir não abre obrigação nem regista recebimento neste recorte |
| Obrigação a receber | Documento exacto único; snapshot do montante e identidade usados; fundamento de abertura sintético; data de abertura; vencimento opcional, origem do vencimento e data de corte da consulta; versão e confirmação | Confirmação explícita abre saldo operacional **de teste**. Não determina exigibilidade do IVA ou reconhecimento contabilístico |
| Proposta de recebimento | Obrigação/cliente/contexto; montante positivo; data declarada do recebimento distinta da data de registo; origem sintética e identificador estável do acontecimento; evidência de teste e hash; versão, proponente, estado | Regista declaração ainda por confirmar. Não reduz saldo nem prova dinheiro em banco |
| Confirmação/recusa de recebimento | ID e versão da proposta; decisão, autor distinto, instante, motivo e referências da evidência | Confirma valor recebido de teste disponível para afectação. Não afecta documento nem reconcilia automaticamente |
| Afectação | ID do recebimento confirmado + obrigação + documento exacto; montante positivo, moeda; autor/instante; versões revistas e comando | Reduz saldo do documento e disponível do recebimento, atomicamente. Uma afectação não é outro recebimento |
| Comprovativo interno | Referência técnica estável da confirmação; recebimento e afectações identificados; valores e data de corte da vista | Consulta interna da evidência. Não é recibo fiscal nem novo evento monetário; reabrir não muda montantes |
| Observação de fonte simulada | Fonte/lote de teste, ID externo único nesse âmbito, cliente, moeda, valor, data, referência exacta do recebimento/documento e hash do conteúdo | Evidência artificial independente. Não cria recebimento ou afectação |
| Correspondência de reconciliação | Observação + recebimento confirmado; valores/datas/referências conferidos, decisão, autor, instante, versão e motivo | Marca **reconciliado em simulação**, nunca reconciliação bancária real. Um vínculo activo por observação e por recebimento neste recorte |
| Correcção/reversão operacional | Tipo, alvo exacto, versão, montante/efeito calculado, motivo, proponente, decisão, autores e vínculos de reversão | Operação compensatória imutável. Não edita, apaga ou substitui o original |
| Documento redutor sintético | ID próprio, tipo `TEST-REDUCTION`, documento e linha originais, montante positivo, moeda, motivo, fotografia e validação; aplicação única à obrigação | Redução de teste independente, sem nota fiscal real. Não deriva de `si_correction_requests` |
| Comando, histórico, evento/outbox | Chave, actor/âmbito/acção, hash canónico, versões, correlação/causação, resultado e estado de processamento separado | Recuperação sem duplicação; eventos específicos, sem efeitos contabilísticos ou externos |
Cardinalidades limitadas: documento 1 → 0/1 obrigação; obrigação 1 → N recebimentos ligados; recebimento 1 → N afectações históricas, todas para essa única obrigação; afectação → 0/1 reversão integral; recebimento → 0/1 reversão integral; recebimento 1 ↔ 0/1 observação activa de reconciliação. Documentos redutores ligam-se à obrigação e original sem alterar a fotografia deste. Não implementar um motor geral de distribuição por carteira.
Vencimento desconhecido guarda `null` e pendência, nunca a data actual, zero ou próxima facturação. Datas declaradas futuras/incompatíveis exigem recusa ou pendência explícita segundo o cenário; nenhum relógio de teste passa a data operacional real. Correcções de vencimento exigem nova versão justificada e autorizada, não edição silenciosa; essa autorização é incluída na proposta de correcções abaixo.
## 4. Estados e transições independentes
| Objecto / dimensão | Estados e transições propostos |
| --- | --- |
| Obrigação: abertura | Proposta → aberta por confirmação; proposta → recusada. Documento inválido/rascunho/B1 bloqueia abertura. Não há abertura automática ao receber evento de emissão |
| Obrigação: saldo | Sem afectação / parcial / liquidada, derivados do saldo líquido; pode voltar a parcial/aberta por reversão aprovada, sem apagar liquidação histórica |
| Obrigação: vencimento | Desconhecido / não vencida / vencida, derivado separadamente para saldo positivo na data de corte explícita. Proposta: vencida quando data de corte civil for posterior ao vencimento; no próprio dia ainda não vencida. Saldo zero: liquidada, sem valor vencido actual |
| Recebimento | Proposto → confirmado ou recusado. Só confirmado participa na disponibilidade; confirmado → revertido por decisão compensatória, respeitando dependências. Recusa e correcção da proposta preservam versões |
| Afectação | Pedido válido → aplicada atomicamente; aplicada → revertida integralmente por decisão própria. Pedido inválido não cria afectação aplicada. Não existe estado aplicado com saldo negativo |
| Reconciliação | Não conferida → correspondência proposta → reconciliada em simulação ou divergente. Divergência não reduz diferenças nem confirma recebimento. Reconciliada → desfeita por decisão rastreável; nunca voltar por edição directa |
| Correcção operacional | Proposta → aprovada/aplicada atomicamente ou recusada. Aprovação desactualizada não executa sobre valores entretanto alterados; requer nova revisão |
| Redução documental de teste | Documento sintético preparado → validado → aplicação explícita única à obrigação. Não converte pedido de correcção fiscal em documento válido |
Não usar um único estado «pago/reconciliado». É admissível documento liquidado com dois recebimentos não conferidos; ou recebimento reconciliado em simulação mas parcialmente por afectar, com documento ainda aberto. Nada aqui fecha período ou determina datas fiscais.
## 5. Aritmética, excepções e preservação
Proposta `SGF-RECEIVABLES-SIM-v1`, a aprovar para os testes. Usar Brick Math no servidor, cadeias decimais na API e armazenamento exacto. Reutilizar o limite técnico de até 16 algarismos inteiros/8 decimais das entradas existentes, validando também resultados e capacidade do armazenamento antes de persistir. Para este recorte MZN, montantes devem ser exactamente representáveis a duas casas: aceitar `400`, `400.0`, `400.00` como iguais; recusar `400.001`, notação exponencial, float e valores fora do limite. Não truncar ou arredondar para caber. Esta proposta de representação de recebimentos não altera a política de IVA, preços ou fotografias aceites.
Definições, só com efeitos confirmados/aplicados e não revertidos, na mesma moeda e data de corte:
- `D`: valor fixado do documento; `C`: reduções sintéticas aplicadas; `A`: soma líquida das afectações activas.
- `Saldo_documento = D − C − A`, sempre maior ou igual a zero.
- `R`: valor confirmado do recebimento ainda não revertido; `AR`: afectações activas desse recebimento.
- `Disponível_por_afectar = R − AR`, sempre maior ou igual a zero.
- Montante a afectar `x > 0` deve cumprir **simultaneamente** `x ≤ Disponível_por_afectar` e `x ≤ Saldo_documento`.
Histórico preserva valores exactos originais e compensações; apresentar separadamente original, reduções, recebido, afectado, por afectar e saldo, sem chamar ao total recebido «pago no documento» se não foi afectado. Saldos actuais são projecções verificáveis dos movimentos, não campos livremente editáveis. Totais agregados que ultrapassem a capacidade declarada provocam erro explícito e rollback, nunca overflow/coerção.
| Excepção | Tratamento proposto |
| --- | --- |
| Recebimento superior ao saldo | Registar/confirmar o valor integral com aviso explícito de excesso. Não o cortar nem afectar automaticamente. Afectação acima do saldo é recusada por inteiro; o utilizador pode submeter afectação menor. Excesso fica por afectar e pendente de decisão futura, sem receita, crédito comercial, adiantamento ou reembolso automático |
| Mesmo pedido repetido | Recuperar resultado e identidades originais, sem outro movimento/evento. Mesmo identificador com conteúdo diferente é conflito |
| Mesmo acontecimento com outra chave | Unicidade da origem + ID estável do acontecimento no âmbito impede nova confirmação/recebimento. Mesmo valor/data/cliente não prova duplicação; sinais de possível duplicado mostram alerta, não fusão ou eliminação automática |
| Fonte sem identificador suficiente | Pendência e confirmação bloqueada até atribuição de identidade interna estável da evidência sintética. Não inventar referência bancária |
| Moeda ausente/diferente, outro cliente/unidade ou documento homónimo | Recusa. Sem conversão, transferência de saldo ou procura aproximada por valor |
| Documento já liquidado | Novo recebimento ligado ao documento é excepção de excesso; não reabre dívida. Afectação positiva recusada |
| Redução deixa dívida líquida inferior ao já afectado | Recusar aplicação, preservando proposta. Exigir reversão explícita da afectação suficiente; depois revalidar redução. A quantia libertada fica por afectar, sem devolução automática |
| Reversão do recebimento com afectações/reconciliação activas | Bloquear até desfazer reconciliação e reverter afectações por operações aprovadas separadas. Sem cascata oculta. Reversão do recebimento cancela registo de teste, não executa estorno bancário |
| Erro em recebimento confirmado | Reversão integral rastreável e nova proposta, ligada ao original, se necessária. Não alterar montante, cliente, unidade, data ou moeda do confirmado |
| Divergência de fonte | Mostrar valor declarado, valor observado e diferença exacta. Não compensar diferença, alterar recebimento ou marcar reconciliado por tolerância |
Reversões são integrais neste primeiro recorte; devolução parcial, abatimento ou transferência exigem extensão. A afectação revertida não volta a aplicar-se por replay do comando original: o replay devolve a operação histórica com o seu estado actual; nova intenção necessita nova chave e revisão.
## 6. Reconciliação apenas simulada e comprovativo
Registar observações artificiais num espaço de teste identificável, sem upload de extractos reais ou acesso a bancos. Propor correspondência por referências explícitas; validar recebimento confirmado, identidade do documento/cliente/âmbito, mesma moeda, montante exacto integral e datas da observação declaradas. A data bancária e a data de recebimento são campos distintos; não forçar igualdade nem inferir uma da outra. Sem regra/evidência suficiente, manter pendência. Não fazer matching automático só por cliente, data e valor.
Conferência é acção explícita, com permissão e autor. Neste recorte, correspondência 1:1 e sem tolerância monetária: 399,99 não reconcilia 400,00. Observação repetida com mesmo ID/hash recupera a original; conteúdo diferente para esse ID gera conflito. Não reutilizar observação já conferida noutro recebimento. A reversão da conferência liberta o vínculo por evento compensatório, conservando a história. Uma observação pendente não gera recebimento e uma afectação não gera observação.
Comprovativo interno apenas no navegador, sem série de recibo: identidade técnica do recebimento, estado, evidência sintética, moeda, valores recebido/afectado/disponível, afectações e respectivas identidades documentais. Mostrar data de corte/versão da consulta e reversões; sem substituir silenciosamente uma vista aberta. Se no futuro houver comprovativo emitido/entregue, exigirá fotografia e regras próprias. Não confundir esta vista com recibo fiscal nem marcar o cliente como tendo recebido um comprovativo por o operador o consultar.
## 7. Exemplos sintéticos e resultados esperados
Todos os exemplos usam fixtures independentes; nunca alterar o caso principal para simular uma variante. IDs abaixo são rótulos, não IDs reais fixos. Documento `DOC-TEST-A`, fotografia de 1 000,00 MZN, obrigação `OB-A` aberta explicitamente, sem vencimento informado. Não é necessário refazer o cálculo fiscal da factura para testar o subpercurso; a fixture de origem satisfaz o percurso de emissão de teste já aceite.
| Passo do caso principal | Recebido acumulado | Afectado acumulado | Por afectar | Saldo OB-A | Reconciliação |
| --- | ---: | ---: | ---: | ---: | --- |
| Confirmar abertura OB-A | 0,00 | 0,00 | 0,00 | 1 000,00 | Não aplicável |
| Confirmar R-A de 400,00 | 400,00 | 0,00 | 400,00 | 1 000,00 | Não conferida |
| Aplicar AF-A de 400,00 | 400,00 | 400,00 | 0,00 | 600,00 | Não conferida |
| Repetir confirmação R-A e AF-A | 400,00 | 400,00 | 0,00 | 600,00 | Sem alteração |
| Confirmar R-B de 600,00 | 1 000,00 | 400,00 | 600,00 | 600,00 | Não conferida |
| Aplicar AF-B de 600,00 | 1 000,00 | 1 000,00 | 0,00 | 0,00 | Ambas não conferidas |
| Conferir OBS-A=400 e OBS-B=600 | 1 000,00 | 1 000,00 | 0,00 | 0,00 | Duas correspondências simuladas |
O saldo era zero **antes** da conferência. Repetir conferências não cria movimentos. Sem vencimento, os passos com saldo positivo mostram «vencimento por definir», não «vencido». Com vencimento sintético 10/11/2026 e corte 11/11/2026, 600,00 em aberto são vencidos; corte 10/11 não produz atraso sob a convenção proposta.
Variantes obrigatórias:
1. **Redução:** documento `DOC-TEST-C` de 1 000,00; R-C=400 afectado → saldo 600. Documento independente `TEST-REDUCTION-C`, 100,00, explicitamente ligado ao original/linha, validado e aplicado uma só vez → dívida líquida 900, afectado 400, saldo 500. R-D=500 afectado → saldo zero. Original continua 1 000,00. Repetição da redução não subtrai outros 100; pedido `si_correction_requests` sozinho não reduz nada.
2. **Excesso:** documento de 1 000; primeiro 400 afectado; segundo recebimento 700 confirmado. Tentar afectar 700 recusa, saldo continua 600 e disponível 700. Afectar explicitamente 600 deixa saldo zero e 100 por afectar. Reconciliação simulada de 700 pode ser conferida como recebimento integral; os 100 continuam pendentes. Nenhuma receita/restituição nasce daí.
3. **Divergência:** R=400, observação=399,99 → diferença 0,01; reconciliação divergente, sem alterar afectação ou saldo. Observação para documento B de igual valor não confere R de documento A.
4. **Reversão:** após caso principal, proposta de reversão de R-B é bloqueada pelas dependências; desfazer conferência de R-B e reverter AF-B reabre saldo 600, com R-B=600 por afectar; reverter R-B deixa recebido líquido 400. Originais, decisões e evidências permanecem. Repetir cada reversão tem efeito único.
5. **Redução após liquidação:** tentar reduzir 100 quando 1 000 já afectados recusa por excesso de afectação. Reverter integralmente a afectação de 600, aplicar redução de 100 e afectar de novo 500, com confirmação própria → saldo zero e 100 por afectar. Não automatizar estes passos ou simular devolução.
## 8. Responsabilidades e permissões — proposta nova, sem concessões
As funções de emissão, série, configuração visual e validação fiscal já aprovadas não mudam. **Não implicam poderes de recebimentos.** Propõe-se separação entre preparação e confirmação, com identidade distinta verificada no servidor, inclusive quando houver delegação organizacional. A utilização de uma sessão de outro utilizador não serve como prova técnica de separação de autores.
| Acção / direito proposto | Osmane — proposta | Samira — proposta |
| --- | --- | --- |
| Consultar percurso — `service_receivables.view` | Sim | Sim |
| Preparar obrigação e recebimento/evidência — `service_receivables.prepare` | Sim | Não |
| Confirmar/recusar abertura de obrigação e recebimento — `service_receivables.confirm` | Não | Sim, distinta do proponente |
| Aplicar afectação explícita de recebimento confirmado — `service_receivables.allocate` | Sim | Não |
| Conferir observação e correspondência simuladas — `service_receivables.reconcile` | Não | Sim |
| Propor redução sintética, reversão/desconciliação ou correcção de vencimento — `service_receivables.correction_request` | Sim | Não |
| Validar/recusar e aplicar atomicamente correcção operacional de teste — `service_receivables.correction_approve` | Não | Sim, distinta do proponente |
Proposta: **oito concessões**, quatro por conta: Osmane com view, prepare, allocate e correction_request; Samira com view, confirm, reconcile e correction_approve. Os nomes representam funções propostas, não regras no código. Antes de aplicar acessos, apresentar contas existentes conferidas, acções e âmbito exactos para confirmação específica. Nenhuma concessão é efectuada por este documento.
Todos estes direitos são da unidade explicitamente autorizada da empresa, condicionados também à pertença e `unit.view`; sem direito empresarial que abra operações de outras unidades. Para uma futura aplicação nominativa, propor apenas Tempo Lda/SEGI, sujeita a confirmação específica; desenvolvimento e demonstração usam identidades e âmbitos sintéticos equivalentes em `sgf_tempo_test`. Sem gestão de concessões, poderes universais ou activação de recebimentos reais. A view dá acesso ao comprovativo interno; não autoriza reabrir PDF/documento sem direito próprio.
## 9. Integridade, concorrência, idempotência e recuperação
Propostas técnicas para futura implementação, sem DDL nesta etapa:
1. **Fronteira de ambiente:** nova guarda específica deste percurso permite mutações/provas só em `testing`/`browser-testing` e após `TestingDatabaseGuard` confirmar `sgf_tempo_test` no cluster próprio. Não depender apenas de etiqueta TEST, frontend ou variável de ambiente. Ambiente normal recusa mesmo com permissões. Não relaxar `ServiceInvoicing::guard()` ou reutilizar a aplicação normal para provas.
2. **Isolamento também na base:** FKs compostas/constraints/triggers asseguram igualdade de tenant/empresa/unidade/cliente/moeda entre obrigação, origem, recebimento, afectação, evidência e reversão. Âmbitos, montantes confirmados e snapshots imutáveis, inclusive UPDATE/DELETE em massa. Eloquent scope não é fronteira completa. Inserções por ligação fora do âmbito devem falhar, não apenas os formulários.
3. **Autorização:** revalidar sessão, CSRF, contexto, pertenças, direitos e versões no pedido e dentro da transacção antes do efeito. Revogação/mudança de contexto invalida formulário aberto; não grava noutra unidade. Tarefas/consumidores também verificam origem e ambiente, sem credenciais de administrador ou bypass.
4. **Unicidades:** obrigação por documento; acontecimento por origem/ID estável/âmbito; observação por fonte/lote/ID; decisão terminal por versão; reversão integral única por alvo; aplicação única de redução; correspondência activa 1:1. Uma nova chave HTTP não ultrapassa unicidades de negócio.
5. **Comando idempotente:** chave obrigatória por âmbito/actor/acção, hash canónico das entradas, alvo e versões. Números equivalentes são normalizados exactamente. Repetição idêntica devolve IDs/efeitos já gravados; alteração de alvo/conteúdo/versão gera conflito. Revalidar direito de consulta antes de devolver replay; não revelar resposta de outro autor/unidade. Mesma chave usada noutro contexto não reutiliza resultado anterior.
6. **Saldos sob bloqueio:** bloquear obrigação, recebimentos e vínculos afectados numa ordem global documentada; revalidar saldo/disponível e versões dentro da mesma transacção. Dois comandos que competem pelo mesmo saldo não podem ambos gastar o valor. Operações por SQL directo também devem passar pelos invariantes da base, com serialização dos agregados; CHECK por linha sozinho não controla somas concorrentes.
7. **Atomicidade:** movimento/decisão, projecção de saldo/versionamento, resultado do comando, auditoria e evento/outbox numa transacção. Falha de qualquer parte desfaz o conjunto. Eventos como `receivable.opened`, `receipt.confirmed`, `allocation.applied/reversed`, `reconciliation.simulated_confirmed/reversed` e `synthetic_reduction.applied` têm schema, origem e causação explícitos. Nada os trata como autorização de lançamento ou transferência.
8. **Resposta perdida:** após commit, retry recupera resultado original; antes do commit, nenhum efeito fica aplicado. Pendente de negócio é diferente de erro técnico. Chave de comando em curso não pode bloquear indefinidamente: usar transacção/timeout e retry limitado, sem criar outro acontecimento para contornar erro.
9. **Concorrência e falhas:** conflito de versão exige nova revisão explícita. Deadlock/serialização pode ter retry técnico limitado com a mesma chave, revalidando tudo; não reutilizar decisão de aprovação sobre estado materialmente diferente. Confirmação simultânea, afectações concorrentes e reversão concorrente com reconciliação devem ter um resultado coerente único.
10. **Recuperação e consumidor:** outbox entregue pelo menos uma vez, consumidor interno idempotente por ID de evento; trabalho/estado de entrega separado do evento imutável. Falha após consumir não duplica projecção. Verificação read-only reconcilia movimentos com saldos derivados; divergência gera falha visível, nunca ajuste automático ou reconstrução do histórico. Sem processamento contabilístico.
## 10. Ecrãs mínimos e critérios de aceitação futuros
Reutilizar login, cabeçalho e contexto existentes. Ecrãs: (1) lista/detalhe de obrigação com identidade documental e saldos discriminados; (2) preparação e fila de confirmação de recebimentos; (3) afectação explícita e comprovativo interno; (4) observações e conferência simuladas; (5) pedidos/decisões de correcção e linha do tempo. Podem ser separadores de um mesmo percurso, sem criar portal ou painel geral de tesouraria. Mostrar apenas acções autorizadas, com recusa também no servidor.
Provas a implementar **só após aprovação**, com login real sintético no navegador e instância isolada, sem confundir `actingAs` com prova de autenticação:
- Caso principal e cinco variantes da secção 7, com identidades e somas exactas explicáveis. Liquidar não reconcilia; reconciliar não afecta nem cria recebimento. Rascunho, B1 e simples pedido de correcção recusados como origem de dívida/redução.
- Vencimento desconhecido, no próprio dia, vencido, saldo parcial e saldo zero em cortes explícitos; ausência nunca vira atraso. Datas fiscais e conformidade do caso real permanecem como estavam.
- Zero/negativo, escala excessiva, limite/overflow, moeda em falta/diferente e dados sem evidência recusados sem escrita parcial; 400,00 não se torna 399,99 por tolerância.
- Repetição de cada comando; mesma chave com conteúdo diferente; nova chave sobre mesma origem/observação; repetição após reversão; não duplicar montantes, decisões, afectações ou eventos.
- Dois processos realmente sobrepostos: mesma confirmação; afectações de 400+700 contra saldo 1 000; duas afectações disputando o mesmo disponível; redução/reversão/conferência concorrentes. No cenário 400+700, no máximo uma delas aplica integralmente com as versões iniciais; a outra é recusada/revista, sem saldo negativo ou divisão implícita.
- Falha injectada antes do commit, na auditoria/outbox e resposta perdida após commit; nenhum órfão e um único resultado recuperável. Consumidor repetido e interrompido não duplica projecção.
- URL/API, ID adulterado, empresa irmã do mesmo tenant, unidade não autorizada, outro cliente e dados históricos; revogação com efeito no pedido seguinte e formulário aberto após troca de contexto. SQL em massa não transfere, apaga ou altera originais nem contorna somas.
- Separação de proponente/confirmador, consulta sem alteração, emissão/administração sem novos poderes; aprovações antigas não autorizam novos movimentos. Nenhum acesso empresarial atravessa unidades.
- Guardas recusam base diferente de `sgf_tempo_test`, ambiente normal mesmo com novos direitos, documento não sintético e tentativa de fixture na base normal. Não alterar ligação da aplicação normal nem terminar processos alheios para libertar portas.
- PDF/fotografia de origem, B1, dados/permissões anteriores e oito concessões de emissão preservados. Executar apenas regressões tocadas pelo incremento, sem repetir suites aceites por rotina. Compilação se houver React; no presente documento não foi executado teste ou build.
## 11. Decisões realmente novas e proposta para aprovação
Não pedir novamente regime/IVA, responsáveis de séries/emissão, série nova, plano contabilístico ou aceitação do cálculo. Não condicionar esta demonstração sintética a uma nova pesquisa fiscal. São novas e ainda propostas:
1. **Circuito operacional e acessos:** confirmar a matriz da secção 8, incluindo confirmação por identidade distinta, afectação por Osmane e reconciliação/correcção por Samira. Concessões concretas continuam sujeitas a confirmação específica posterior.
2. **Tratamento das excepções:** confirmar excesso explicitamente por afectar; sobreafectação recusada por inteiro; sem automatismo de reembolso/crédito; reversões integrais com dependências explícitas; redução posterior à liquidação bloqueada até libertar afectação suficiente.
3. **Convenções do ensaio:** confirmar MZN explícito, representabilidade exacta a duas casas, vencido só após o dia de vencimento, ausência de vencimento como pendência, reconciliação 1:1 sem tolerância e comprovativo interno apenas no navegador. São decisões operacionais deste recorte, não validação fiscal de recibos.
**Proposta concreta:** autorizar futuramente um único incremento técnico isolado `SGF-RECEIVABLES-SIM-v1`, com abertura explícita de obrigação sobre documento de teste existente, recebimentos 400/600, confirmação separada, afectações manuais exactas, saldo derivado, comprovativo interno, conferência de duas observações sintéticas, variantes de excesso/divergência/reversão/redução e eventos duráveis. Reutilizar contexto, aritmética e padrões já entregues; acrescentar apenas entidades, comandos e vistas necessários a esse percurso. Não criar motor financeiro geral.
A futura execução deve apresentar o delta aditivo e base de destino antes de qualquer migração em desenvolvimento e as concessões exactas antes de aplicação. Este documento **não aprova nem executa** esses passos. Implementação/demonstração continua restrita à base de testes, sem operações reais, autorização de emissão, recibo fiscal, banco, contabilização ou fecho. Série/configuração real e emissão permanecem bloqueadas; as pendências de conformidade não são encerradas por esta proposta.
## 12. Aprovação do âmbito
Em 27/10/2026, após apresentação da secção 11 e da matriz de permissões, o utilizador respondeu **«confirmo o ambito»**. Ficam aprovados o recorte `SGF-RECEIVABLES-SIM-v1`, o circuito e matriz funcional da secção 8, o tratamento das excepções e as convenções de ensaio da secção 11. As referências anteriores a estas decisões como propostas pendentes são históricas; não devem voltar a ser submetidas como decisões novas.
A aprovação da matriz funcional não aplica as oito concessões: contas, acções e âmbitos exactos continuam sujeitos à confirmação específica antes da aplicação. Mantém-se a apresentação prévia do delta aditivo e base de destino antes de qualquer migração em desenvolvimento. O presente registo apenas documenta a aprovação do âmbito; não executa implementação, testes, migrações ou escrita operacional.
Recebimentos e reconciliação mantêm-se exclusivamente sintéticos em `sgf_tempo_test`, sem recibos fiscais, dinheiro, lançamentos, fecho ou publicação. Preservar `4276aca` e alterações posteriores, aprovações existentes e bloqueio de emissão/configuração real.
## 13. Execução técnica posterior autorizada
Em 27/10/2026, o utilizador autorizou expressamente implementar `SGF-RECEIVABLES-SIM-v1`, código/testes e demonstração apenas no ambiente isolado. A entrega e os resultados estão em [SERVICE_RECEIVABLES_DELIVERY.md](SERVICE_RECEIVABLES_DELIVERY.md). As restrições anteriores a implementação referiam-se à etapa documental e foram substituídas apenas neste âmbito técnico. Migração em desenvolvimento e aplicação das oito concessões reais continuam sem execução e sujeitas às confirmações específicas previstas. A autorização não retira o bloqueio de emissão real nem constitui aceitação da entrega.
+244
View File
@@ -0,0 +1,244 @@
# Oficinas e assistência técnica — âmbito para revisão
27/10/2026, Africa/Johannesburg. Versão 0.1. **Apenas especificação.** Não autoriza implementação, dados operacionais, acessos, migrações ou publicação.
## 1. Referências e base preservada
Directório confirmado: `C:\laragon\www\sgf-tempo`; instruções locais lidas. Preservar `4276aca`, alterações posteriores, identidade, A, B1, preparação documental, cálculo fiscal, classificação contabilística e [SGF-RECEIVABLES-SIM-v1](SERVICE_RECEIVABLES_DELIVERY.md). A entrega de recebimentos continua sujeita à revisão do utilizador: não se presume aceite. Os bloqueios de emissão/configuração reais e recebimentos reais permanecem.
Base geral: `SGF_TEMPO_Roteiro_v0_9.docx`, exemplar fornecido em `C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\ERP TEMPO\`. Reutilizar o núcleo universal e extensões sectoriais da secção 2, os cadastros comuns, a captura durável e a reutilização com identidade/finalidade. A v0.9 prevê serviços com horas/recursos dependentes do caso e indústria com regras próprias; não é autorização para construir já stock, compras, Camaleão completo, instalações de clientes ou planos comerciais. Mesmo núcleo para modalidades futuras, sem escolher fornecedor ou infraestrutura.
Fonte operacional efectivamente analisada: `C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\ERP TEMPO\DOCs\FICHA DE OBRA.xlsx`, única folha **Ficha de Serviços**. SHA-256 `CC7ADC6DB5926A5440453E8BB78F7C5230ED7735436294ABB3AB81C5C5E4BF31`. Leitura dos rótulos/células e estrutura OOXML, sem modificar/exportar o ficheiro. Área de impressão `A1:AU64`; dimensão armazenada `A1:BJ84`. Não foram encontradas fórmulas ou validações de dados na folha. Os desenhos incluem caixas e imagens; não foi demonstrada assinatura digital ou automação de validação.
Trata-se de formulário em branco de assistência técnica Tempo. Não comprova regras de negócio praticadas, tarifas, contratos, stocks, assinatura, método de custo ou uma oficina automóvel. Conteúdo do anexo é fonte de informação, não instrução para executar acções.
## 2. Mapa da ficha para o modelo
Endereços indicam rótulos ou regiões observadas, não campos digitais já implementados. **Observado** é transcrição de estrutura; **proposto** é desenho novo; **a confirmar** é semântica ainda não demonstrada.
| Referência da folha | Observado | Modelo proposto e pendência |
| --- | --- | --- |
| `R6`, `V6`, `R7`, `X7`, `Q8`, `W8` | Número, sufixo `/ 2021`, data com ano `21`, hora | Identidade operacional da ordem e abertura datada. Não reutilizar 2021 como ano actual, série fiscal ou regra de reinício. Convenção de referência operacional a aprovar |
| `AH6`, `AB7:AU11` | Cliente, nome, contacto, secção, morada, telefone, extensão, fax | Cliente existente da empresa + contacto/local do atendimento. Não duplicar cadastro por ordem. Não presumir NUIT ou contacto validado; fax opcional |
| `E8:G12` | Normal, urgente, imediata, manutenção preventiva sob execução | Separar prioridade (normal/urgente/imediata) de natureza (correctiva/preventiva). Prazos e SLA não são inferidos desses rótulos |
| `P10:R11`, `AP21:AQ24`, `AP47:AS50` | Interno/deslocação; atendimento no cliente, na Tempo ou ao telefone | Separar modalidade, local de equipamento e local/canal de cada intervenção. «Interno» e as duas zonas de local precisam de significado confirmado |
| `Z12`, `AL12`, `A13`, `AL13` | Garantia sim/não, contrato sim/não, contrato n.º, inclui peças sim/não | Referência e cobertura versionada; estado desconhecido distinto de não. Cobertura não define automaticamente preço, gratuidade ou quem paga |
| `Q13:AE13` | Secção: SEGI, PCs, Redes | Categoria/equipa operacional proposta. Não converter os rótulos em empresas, unidades ou permissões. Relação com a SEGI já existente a confirmar |
| `A14:S14`, `A15:AU20` | Equipamento na Tempo/no cliente; marca/modelo por unidade, número de série | Bem individual e custódia/localização. A expressão «por unidade» aqui identifica equipamento, não a unidade organizacional do SGF. Primeiro recorte: um bem por ordem |
| `AL14`, `A21:AO25` | Preenchida por; avaria ou serviço pretendido | Autor da abertura e pedido original preservado. Diagnóstico e proposta são registos posteriores distintos do relato do cliente |
| `A26:AU26` | Trabalho a executar por, recebi, data/hora | Responsáveis e passagem de responsabilidade/custódia. Confirmar quem «recebi» representa; não o tratar como recebimento de dinheiro |
| `A28:AU35` | Requisições/componentes/material aplicado; n.º requisição, datas, código, descrição, quantidade, n.º entrega, valor unitário/total, total | Referências de material e acontecimentos separados. «Quantidade» não prova consumo; «valor» não distingue custo/preço, nem mostra moeda/imposto. Não há prova de reserva, devolução ou valorização de stock |
| `A37:AU45` | Mão-de-obra, deslocações, técnicos; data, início/fim, S/N, tempo, tempo total, técnico, classe, valor total e rubricas | Intervalos/pausas, deslocações e classificação profissional a propor. `S/N` e «classe» precisam de definição; não presumir tarifa. Hora decorrida não é tempo efectivo ou facturável |
| `A46:AO56`, `AP52:AS55` | Relatório técnico; concluído não/sim/com sucesso | Relatório versionado, conclusão técnica e resultado do teste funcional separados. «Com sucesso» exige critério/evidência definidos; não implica aceite ou entrega |
| `A58:AU58` | Executado por, assinatura, data | Identificação/evidência de revisão/conclusão. Campo de assinatura em branco não comprova assinatura electrónica ou identidade verificada |
| `A61:AU62` | Cliente confirma boa execução, peças, horas e deslocações; nome/assinatura/data | Aceite com pessoa, qualidade em que intervém, data, versão e evidência; distinguir recusa/ressalvas. Não equivale a autorização prévia, pagamento ou recepção automática de factura |
| `A64:AU64` | Identificação e contactos Tempo no rodapé | Reutilizar dados aprovados do emitente. Não copiar contactos de um modelo antigo como configuração actual |
**Campos novos propostos, não observados na ficha:** ID do equipamento, proprietário/custódia, diagnóstico separado, autorização por versão/limite, pausas, tempo efectivo, decisões de facturabilidade, custo interno, moeda explícita, vínculos transaccionais ao rascunho e eventos. Matrícula, quilometragem, chassis, combustível e outros campos de automóvel são extensões opcionais, ausentes deste recorte.
## 3. Menor percurso útil e entidades
Primeiro recorte proposto: assistência técnica a um equipamento de um cliente, numa empresa/unidade, uma moeda comercial explícita, tarefas simples, apontamentos manuais verificados e um único rascunho de facturação. Sem gestão automóvel, múltiplos bens na ordem, stock real, compras, agenda optimizada, contratos recorrentes ou assinatura digital.
| Entidade | Campos e ligações essenciais |
| --- | --- |
| Ordem de trabalho | ID/ref. operacional, tenant/empresa/unidade, cliente, equipamento, pedido original, abertura, prioridade, natureza, local/canal, responsável, versão, origem da ficha e estado |
| Equipamento/bem | ID empresarial, proprietário/cliente, tipo, marca/modelo, série quando conhecida ou identificador interno quando ausente; local, custódia, acessórios/estado de entrada e evidências. Não confundir com activo contabilístico da empresa |
| Diagnóstico e proposta | Versão, tarefas/recurso esperado, limites, dependências, pressupostos e eventual orçamento com moeda, quantidades/preços propostos. Custo interno em vista protegida |
| Autorização do cliente | Versão exacta da proposta, pessoa/representação, canal, data, evidência, alcance/limite e ressalvas. Quando dispensada segundo política aprovada, guardar fundamento e responsável; ausência não é consentimento |
| Tarefa/intervenção | ID, ordem, serviço existente quando compatível, objectivo, técnico, resultado, intervalos/pausas, recursos, versão da autorização abrangente e evidências |
| Apontamento de tempo | Técnico/recurso estável, ordem/tarefa, início/fim com zona, pausas, tipo trabalho/deslocação, tempo efectivo verificado e estado; facturabilidade separada |
| Material na ordem | Identidade/designação/unidade, proprietário empresa/cliente, origem, quantidades por acontecimento, documento/referência e evidência. Consumo técnico observado não é saída de inventário |
| Decisão técnica/comercial | Linhas/versões revistas; quantidade executada/consumida versus facturável, preço, custo com estado conhecido/provisório/em falta/não aplicável, motivo e actor; contrato/garantia/retrabalho/cortesia identificados |
| Relatório, aceite e entrega | Relatório técnico versionado, testes/resultados, conclusão; aceite distinto com ressalvas/recusa; entrega física/remota e custódia, destinatário, data/evidência. Nada implica pagamento |
| Preparação de facturação | Versão revista da ordem e linhas, IDs de prestação/rascunho existentes ou gerados controladamente, ligações por linha/quantidade, estado da conversão e chave de comando |
| Histórico/eventos | Origem/versão, autor, instantes, motivo, âmbito, correlação e schema; aprovação e efeitos ligados ao conteúdo exacto, sem segredos ou dados pessoais desnecessários |
Cliente e serviços pertencem à empresa e são reutilizados entre unidades autorizadas. Equipamento identificado pela empresa e proprietário, consultável apenas para finalidades/âmbitos permitidos; localizar um equipamento não dá acesso às ordens de outra unidade. A ordem, seus recursos e operações pertencem à unidade e não podem ser transferidos por um campo do formulário. Evitar recolher credenciais de dispositivos, palavras-passe, PINs ou conteúdo pessoal em notas/anexos; descrever apenas evidência necessária.
## 4. Estados e transições
Percurso: abertura → diagnóstico/proposta → autorização quando aplicável → execução → revisão técnica/comercial → aceite/entrega → preparação de rascunho. Estados próprios evitam um único «concluído» que esconda trabalho pendente.
| Dimensão | Estados/transições propostos e condições |
| --- | --- |
| Ordem operacional | Aberta → em diagnóstico → proposta preparada → autorizada/em execução → execução terminada → em revisão → concluída tecnicamente. Pode aguardar informação/material/cliente com motivo; cancelamento preserva trabalho/custos/evidência já registados |
| Autorização | Não avaliada / necessária pendente / autorizada parcialmente ou integralmente / recusada / dispensa fundamentada. Só tarefas cobertas pela versão autorizada/dispensa validada podem iniciar execução |
| Tarefa | Planeada → autorizada → em execução/pausada → executada → verificada; não aplicável/cancelada com motivo. Nova tarefa fora do alcance aprovado exige alteração de proposta e decisão própria |
| Aceite do cliente | Pendente / aceite / aceite com ressalvas / recusado / não aplicável fundamentado. Conclusão técnica não altera automaticamente este estado |
| Entrega/custódia | Não entregue / pronta para entrega / entregue com evidência; atendimento remoto pode ter entrega do resultado ou não aplicabilidade fundamentada. Não deduzir entrega da assinatura de execução |
| Facturação | Não revista / pendente de decisão comercial / linhas aprovadas / preparada em rascunho / convertida por ligação. Não mostrar «facturada» por criar rascunho. Emissão continua bloqueada |
Proposta inicial: conversão só após revisão técnica e comercial concluídas e aceite/entrega resolvidos (incluindo não aplicabilidade fundamentada). Facturar antes de entrega ou sob disputa é extensão sujeita a regra própria, não efeito de urgência. Trabalho urgente não dispensa autorização automaticamente. Alteração material de tarefa, quantidade, limite ou preço gera nova versão e revalidação apenas do que afecta; autorizações antigas não abrangem automaticamente o acréscimo. Garantir possibilidade de guardar diagnóstico/pedido incompletos sem fingir aprovação.
Conclusão e reabertura técnica exigem actor autorizado, motivo e nova versão. Relatório/aceite históricos não se apagam; novas evidências referem a versão e eventuais ressalvas. Se uma ordem já originou rascunho, a reabertura marca-o desactualizado; nunca reescreve B1, fotografia ou documento emitido de teste.
## 5. Tempos e deslocações
- Registar instantes completos, data e zona, com fim posterior ao início, pausas dentro do intervalo e sem sobreposição entre pausas. Intervalo incompleto não é zero horas nem trabalho confirmado. Rejeitar duração negativa e fim anterior; passagem de dia é permitida com datas explícitas.
- Tempo efectivo de uma intervenção = união dos intervalos de trabalho verificados menos pausas verificadas; conservar componentes. Mostrar também duração decorrida. Registo manual precisa de origem/autor/validação, não de cronómetro fictício.
- Unidade canónica proposta: minuto inteiro; guardar instantes com precisão de segundo e rejeitar confirmação de durações não representáveis nessa unidade até definir política. Conversão para horas usa minutos/60 exactamente; se decimal não finito, conservar razão exacta e mostrar precisão/pendência de representação, sem truncar silenciosamente.
- Bloquear dois intervalos activos ou confirmados sobrepostos para o mesmo técnico/recurso entre ordens da mesma empresa, incluindo SQL/concorrência na futura implementação. Trabalho simultâneo real exige repartição documentada cuja soma não exceda o tempo disponível, como extensão. Dois técnicos podem trabalhar em paralelo, cada um com o seu apontamento; somar horas-pessoa, sem duplicar o intervalo individual.
- Deslocação tem ID, origem/destino minimizados, técnico/recurso, datas, duração e unidade comercial própria (viagem, tempo, distância apenas se acordada). Não somar o mesmo intervalo como trabalho e deslocação. «Deslocação S/N» não determina custo, tarifa, quilómetros ou quantidade facturável.
- Tempo facturável é decisão comercial distinta. Contrato, garantia e retrabalho conservam tempo/custo mesmo quando a quantidade comercial for zero com decisão explícita. Não derivar preço de «classe» sem tabela aprovada/versionada.
## 6. Materiais, custos e decisão comercial
| Conceito | Significado e controlo proposto |
| --- | --- |
| Requerido | Necessidade planeada; não prova disponibilidade, reserva ou custo |
| Reservado | Compromisso de disponibilidade por sistema de stock; **indisponível no primeiro recorte**, porque não há stock operacional implementado |
| Entregue | Passagem física/custódia documentada à equipa/cliente; não implica instalação ou consumo |
| Consumido/aplicado | Observação técnica verificada de utilização na ordem; quantidade positiva, unidade, origem e autor. Não é movimento de inventário confirmado pelo SGF |
| Devolvido | Retorno físico de quantidade entregue não utilizada, com destinatário/evidência; não reverte consumo que ocorreu. Rectificar consumo exige operação compensatória própria |
Para material em custódia verificada na ordem: entregue = consumido + devolvido + remanescente + outras saídas/perdas explicitamente justificadas, na mesma unidade e corte. Enquanto faltar uma parcela/evidência, mostrar divergência ou pendência, não criar reserva/entrada/saída para fechar a soma. Consumo acima do entregue confirmado requer origem suplementar documentada, não sobreposição ou saldo físico negativo oculto. Materiais adquiridos/levados directamente ao cliente requerem origem equivalente; não inventar n.º de requisição.
Primeiro recorte propõe registo operacional de material observado, com documentos externos ou evidência sintética; sem saldo de armazém, disponibilidade garantida, método de valorização ou encomenda. Futuro stock será dono do movimento físico/económico canónico, e a ordem ligará esse mesmo ID em vez de criar outro consumo. Uma linha de factura não é fonte de saída de material.
Material da empresa e material fornecido pelo cliente têm propriedade/custódia distintas. Material do cliente não se torna compra, activo ou custo de aquisição da empresa e não se vende por omissão; eventuais custos de manuseamento/intervenção ficam separados. Se precisar de valorização para seguro/custódia, é outra finalidade e regra, não custo de produção ou preço de venda.
Para cada recurso, guardar separadamente quantidade real verificada, quantidade comercial aprovada, unidade, custo interno unitário/total e fonte/versão/estado, preço proposto/versão e motivo de diferença. Custo ausente é `null` com pendência, não zero. Zero só com fundamento confirmado; preço não substitui custo. Se a soma de custos estiver incompleta, mostrar subtotal conhecido e componentes em falta, sem custo total ou margem completa.
Contrato, garantia, retrabalho e cortesia identificam um motivo a avaliar: responsável comercial decide cobertura, destinatário da cobrança, quantidades/preço e eventual exclusão. Primeiro recorte só um cliente pagador; cobrança a fabricante/seguradora é extensão. Proposta: quantidade facturável não excede quantidade verificada sem justificação comercial específica; mínimos, pacotes, tarifa fixa ou cobrança por resultado requerem regra própria e não são inventados. Uma quantidade comercial zero exige decisão explícita e conserva custo/consumo.
## 7. Conversão controlada para rascunho
1. Rever versão exacta da ordem, autorizações/dispensas, execução verificada, relatório, aceite/entrega, decisão comercial por linha, moeda/unidades e permissões. Fixar revisão com linhas elegíveis e linhas excluídas/motivos. Informação fiscal em falta permanece pendência do rascunho, não imposto zero.
2. Converter por comando explícito e idempotente: origem/versão de cada intervenção ou consumo, quantidade comercial aprovada, serviço/descrição/unidade, preço e preparação documental reutilizados. Criar/ligar uma prestação de A e um rascunho de facturação, **sem duplicar execução, apontamentos ou consumo**. A prestação é uma referência comercial derivada, não outro acontecimento técnico.
3. Primeiro recorte: uma ordem → uma prestação derivada → um rascunho, com unicidade na base por ordem e por linha de origem seleccionada. Sem agrupamento de ordens ou facturação parcial. Essas extensões exigem quantidades já convertidas/reservadas e concorrência por saldo, não apenas retirar a unicidade.
4. Linhas de mão-de-obra/prestações usam catálogo de serviços existente. Materiais exigem tipo de linha comercial compatível; A suporta actualmente linhas de serviço. **Não representar uma peça como serviço fictício só para converter.** Propor delta explícito de tipo de linha material, sem stock nem reconhecimento, antes de executar esse recorte; alternativa de facturação por pacote só se for a realidade comercial aprovada. Esta dependência não está já entregue.
5. Transacção única para prestação/rascunho, vínculos de origem, revisão, auditoria, evento e resultado do comando. Mesma chave/conteúdo recupera IDs; chave alterada não duplica a ordem/linha já convertida. Mesmo comando com conteúdo diferente é conflito. Falha antes do commit desfaz o conjunto; resposta perdida após commit recupera o mesmo rascunho.
6. Não sincronizar silenciosamente edições posteriores. Alteração da ordem invalida prontidão do rascunho e exige revisão explícita da ligação/versões. Um rascunho modificado comercialmente também torna a revisão anterior incompatível; não permitir contornar origem/quantidade por edição livre. B1 e documentos históricos continuam imutáveis; alteração após confirmação requer novo âmbito, sem duplicar ou renumerar.
Criar rascunho não emite factura, gera receita contabilística, obrigação exigível ou recebimento. Reutilizar [preparação documental](DOCUMENT_PREPARATION_DELIVERY.md), [pré-visualização](DRAFT_INVOICE_PREVIEW_DELIVERY.md) e suas pendências; não retirar bloqueios fiscais para demonstrar oficina.
## 8. Responsabilidades e acessos propostos por função
São funções a designar, sem atribuição automática a Osmane, Samira ou técnicos. A delegação pode ser parametrizada com alcance, vigência e auditoria; não impor número fixo de pessoas nem presumir delegação a partir do login.
| Função proposta | Acções delimitadas | Exclusões |
| --- | --- | --- |
| Atendimento | Abrir ordem, ligar cliente/equipamento, registar pedido/custódia e evidência de autorização/entrega | Não confirma cobertura comercial, custos, margem ou assinatura digital |
| Responsável técnico | Diagnóstico/proposta técnica, distribuição, validar apontamentos/consumo, concluir/reabrir e validar relatório | Não decide preços/garantia ou concede acesso a custos por ser técnico |
| Técnico executor | Consultar ordens atribuídas, registar intervenções/tempos/materiais observados e relatório preliminar | Sem consulta de margens/custos alheios, revisão comercial, conversão ou administração por omissão |
| Responsável comercial | Validar cobertura, preço e quantidade facturável, motivos de exclusão; autorizar conversão de linhas revistas | Não valida automaticamente tempo executado, custo ou fiscalidade |
| Validador de custos | Conferir custo/fonte/versão e acesso limitado aos dados necessários | Não altera preço, salário de origem ou stock por esta função |
| Preparação de facturação | Converter revisão aprovada e consultar vínculos/rascunho com direitos próprios de A/documentais | Sem emissão, recebimentos, acesso a custos ou administração implícitos |
| Consulta operacional / análise de custos | Direitos separados para ordem operacional e para custo/margem autorizados | Consulta de ordem não dá dados de remuneração, custo ou margem |
Catálogo proposto de direitos, cada qual explícito: `work_orders.view/create/assign/diagnose/execute/review_technical/record_authorization/record_acceptance/record_delivery/review_commercial/prepare_billing`; `work_order_costs.view/validate`; `work_order_margins.view`. Âmbito de empresa/unidade e, quando aplicável, atribuição ao técnico. Nomes são proposta técnica, não permissões instaladas. Registar autorização/aceite é conservar evidência, não representar o cliente sem suporte. Acesso aos cadastros empresariais não revela intervenções de outras unidades. Exportações/eventos/modelos de leitura também filtram custos e dados pessoais.
## 9. Cenário sintético
Empresa Exemplo, Unidade Técnica, Cliente Exemplo, ordem `OT-TEST-1`, equipamento PC de teste `EQ-1`/série `SER-TEST-1`. Moeda comercial e de custo **MZN explicitamente definida**. Valores inventados apenas para demonstrar o desenho; sem perfil fiscal, stock ou operação real. Pedido: diagnóstico e reparação simples. Proposta v1 autorizada, relatório v1 verificado, aceite e entrega registados separadamente com evidências artificiais.
| Recurso e unidade | Quantidade executada/consumida | Custo unitário conhecido | Custo conhecido | Quantidade facturável aprovada | Preço unitário proposto, antes de imposto por apurar | Valor comercial proposto |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Cabo de teste da empresa, UN | 2 | 120,00 | 240,00 | 2 | 180,00 | 360,00 |
| Reparação, H | 1,75 | 200,00/H | 350,00 | 1,75 | 600,00/H | 1 050,00 |
| Teste adicional, H, não facturável por cortesia expressamente aprovada | 0,25 | 200,00/H | 50,00 | 0 | Não aplicável à linha excluída | 0,00, por decisão comercial |
| Deslocação, VIAGEM | 1 | 150,00/VIAGEM | 150,00 | 1 | 200,00/VIAGEM | 200,00 |
| Totais deste conjunto de recursos | — | — | **790,00** | — | — | **1 610,00 MZN**, antes de imposto por apurar |
Reparação: 09:00–11:00, pausa 10:00–10:15 → 105 minutos = 1,75 H. Teste adicional: 11:00–11:15 → 15 minutos; deslocação 11:15–11:45 → 30 minutos, registada separadamente e comercializada por viagem, não como mais 0,5 H de reparação. Não há sobreposição do técnico. Material: 3 UN entregues segundo evidência sintética, 2 consumidas e 1 devolvida; 3 = 2 + 1. Não é movimento de armazém ou confirmação de disponibilidade.
O custo 790,00 cobre só os quatro recursos do exemplo, não custos indirectos completos. A diferença 1 610 − 790 = 820,00 é diferença sobre custos identificados, **não margem completa comprovada nem lucro**. IVA e total com imposto permanecem por apurar; não aplicar 16% por hábito.
Variante: custo unitário do cabo desconhecido → custo do cabo pendente, subtotal conhecido 350 + 50 + 150 = **550,00**; custo total e diferença/margem indisponíveis. O preço proposto de 360 não preenche o custo em falta. Material fornecido pelo cliente, se acrescentado, conserva propriedade e evidência, sem preço/custo de aquisição da empresa presumidos.
## 10. Reutilização concreta e dependências
| Base existente | Reutilização | O que falta construir |
| --- | --- | --- |
| Identidade/contexto/empresa/unidade e auditoria | Mesma sessão, pertenças, CSRF, revisão de contexto e direitos explícitos | Direitos sectoriais e atribuição operacional, sem herança automática |
| Clientes e serviços de A | IDs e versões empresariais existentes, mesma preparação documental | Bem individual, ordem, intervenções/recursos e ligações comerciais por origem |
| Tempos/material | Não foi identificado módulo operacional entregue destas matérias nas entregas consultadas | Registo verificado de tempo/custódia/consumo observado; não declarar stock ou folha salarial existentes |
| B1 e documentos/pré-visualização | Fotografias e pendências preservadas; consulta histórica separada | Conversão controlada e, se aprovado, linhas comerciais de material sem stock fictício |
| Classificação contabilística | Categorias/associações aprovadas podem ser referidas sob âmbito e vigência | Não resolvem custeio, reconhecimento, IVA ou propriedade do equipamento |
| Eventos/outbox/comandos | Reutilizar padrão transaccional, idempotência e histórico, com novos tipos/versões | Eventos de ordem/tempo/consumo/revisão/conversão; nenhum consumidor contabilístico activado |
| Recebimentos sintéticos | Preservar entrega e semânticas separadas | Nenhum vínculo automático da ordem/aceite a recebimento, dívida ou reconciliação |
Reservas/stock, compras, remunerações, contratos/garantias automatizados, assinatura electrónica, múltiplas localizações e tarifários são dependências/extensões, não capacidades presumidas. Referências manuais verificadas podem servir ao primeiro recorte, claramente sem garantia de integração. A base comum de recursos/quantidades/eventos deve ser coordenada com [custos industriais](INDUSTRIAL_COST_ACCOUNTING_SCOPE.md); uma ordem técnica não vira ordem de produção automaticamente.
## 11. Critérios de aceitação futuros
- Demonstrar ordem de um equipamento até rascunho sem recapturar cliente, tarefa, tempo, material e preço já aprovados. Linha de cortesia permanece na história e fora do montante facturável.
- Ausência de série/equipamento identificável, cobertura, autorização necessária ou decisão comercial produz pendência específica. Guardar pedido incompleto não o declara autorizado ou pronto a facturar.
- Conclusão técnica, teste funcional, aceite, entrega e rascunho mantêm estados independentes; recusa/ressalva do cliente não desaparece. Rubrica em formulário não se apresenta como assinatura digital validada.
- Intervalos/pausas inválidos, sobreposição individual e duplo cômputo trabalho/deslocação recusados, inclusive entre duas ordens concorrentes. Conversões de unidades exactas, sem arredondamento silencioso.
- Quantidades requerida/reservada/entregue/consumida/devolvida distintas; sem stock instalado não apresentar saldo de armazém ou reserva confirmada. Custo desconhecido não vira zero/preço de venda.
- Revisão/comando antigo, alteração de autorização, edição posterior da ordem/rascunho e resposta perdida não duplicam prestação, material, rascunho ou vínculo de origem. Converter duas vezes com chaves diferentes também não duplica.
- Falha injectada em auditoria/outbox desfaz conversão integral; retry recupera resultado. Histórico confirmado protegido também contra SQL em massa. Nova versão/correcção referencia originais.
- Negar URL/API/tarefa para empresa irmã do mesmo tenant, unidade ou ordem não atribuída. Revogação e troca de contexto têm efeito no pedido seguinte. Técnico/consulta operacional não vê custos/margens por respostas API, eventos ou exportação.
- Provas futuras só com fixtures sintéticas e `sgf_tempo_test`, login real na instância isolada, guardas preservadas. Não é necessário executar testes para esta especificação documental.
## 12. Decisões novas e menor incremento proposto
Proposta **WORKSHOP-ASSISTANCE-DRAFT-v1**: uma ordem/equipamento/cliente/unidade, diagnóstico/proposta versionada, autorização registada com evidência, intervenções/tempos e materiais observados, revisão técnica/comercial, aceite/entrega e conversão única para rascunho. Sem stock/compras/produção, assinatura digital, factura/recebimento ou lançamento. Para materiais na conversão, incluir explicitamente o delta mínimo de tipo de linha comercial ou restringir a conversão a serviços por decisão de âmbito, sem ocultar peças como serviços.
Decisões que efectivamente alteram o desenho:
1. **Recorte comercial de materiais:** converter peças discriminadas no rascunho (com delta de linha material) ou começar apenas pelos serviços mantendo materiais como recursos de custo? Recomendação: discriminadas se a ficha representar a prática comercial; não presumir.
2. **Autorizações e evidências:** em que casos se exige autorização antes de diagnóstico/execução, quem pode fundamentar dispensa e que evidências de aceite/entrega são admitidas? Proposta: referência documental/manual verificada, sem assinatura digital.
3. **Funções e confidencialidade:** designar responsáveis e alcance de ordens atribuídas, revisão técnica/comercial e custo/margem. Nenhuma conta recebe poderes nesta proposta.
4. **Semântica da ficha:** confirmar «S/N» de deslocação, «classe» do técnico, «interno», «recebi» e secção SEGI/PCs/Redes apenas quando forem activados; manter campos neutros/pendentes até lá. Não bloqueiam a especificação nem autorizam fórmulas presumidas.
Referência de numeração operacional, unidade canónica de tempo e regra de revisão da conversão serão fechadas no recorte de execução, sem configurar série fiscal. Não é necessário reabrir as decisões aceites de identidade, contabilidade ou cálculo para aprovar esta componente documental. Nada foi implementado ou alterado nas entregas existentes nesta etapa.
## 13. Primeiro recorte coordenado — ordem e recursos
Em 27/10/2026, o utilizador propôs começar pela ordem de oficina com materiais e tempos de origem identificada, para alimentar custos posteriormente sem duplicação. Esta orientação reduz a primeira entrega face a `WORKSHOP-ASSISTANCE-DRAFT-v1`: **não inclui ainda conversão para facturação**. O âmbito técnico abaixo é proposta concreta de delimitação, não ordem de implementação ou concessão de direitos. A aceitação de recebimentos permanece separada.
### Base partilhada confirmada no desenho
| Base comum | Registo único | Uso posterior sem recaptura |
| --- | --- | --- |
| Empresa/unidade, recurso e unidade de medida | Identidades e âmbito explícitos da ocorrência | Filtrar e validar destino da análise; nunca abrir outra unidade por herança |
| Material aplicado/devolvido | ID do acontecimento, quantidade, propriedade, ordem/tarefa, data, fonte e versão | Acrescentar valorização e destino de custo ligados ao mesmo ID; não inserir outro consumo |
| Tempo de técnico/recurso | Intervalos/pausas e tempo efectivo verificado, com ID e versão | Aplicar taxa/composição de custo futura com fonte própria; não copiar apontamento como outra hora trabalhada |
| Origem, histórico e eventos | Referência da evidência, autor, instante, correcções e correlação | Calcular sob versões explícitas; repetição do evento não duplica recurso ou custo |
Identidade/contexto, clientes/serviços e padrões de auditoria/eventos já existem. Apontamentos de tempo, acontecimentos de material, ordem de oficina e motor industrial continuam por construir. Uma base comum de desenho não significa capacidades já instaladas.
### Proposta `WORKSHOP-RESOURCE-RECORD-v1`
1. **Abrir uma ordem** de uma empresa/unidade, um cliente e um equipamento identificados, com pedido, localização, responsável e referência da autorização/evidência quando necessária. Reutilizar cliente e serviço existentes quando aplicáveis. Sem motor de orçamentos/contratos; ausência de autorização necessária mantém pendência e impede apresentar trabalho como autorizado.
2. **Registar tarefas/intervenções e tempos**, com técnico, início/fim, pausas, tipo trabalho/deslocação, tempo efectivo e evidência. Aplicar as validações da secção 5, incluindo sobreposição do mesmo recurso entre ordens. Não calcular automaticamente horas facturáveis.
3. **Registar materiais observados**, identificando recurso, unidade, proprietário empresa/cliente, fonte e ocorrências de entrega, aplicação e devolução quando efectivamente conhecidas. Informação incompleta fica pendente. Sem reserva, disponibilidade ou movimento de inventário simulado.
4. **Rever e verificar os apontamentos** por função autorizada; conservar divergências e versões. Estados mínimos da ordem: aberta, em registo, a aguardar informação, em revisão e registos verificados. Esta última expressão não significa conclusão técnica, aceite, entrega, facturação ou fecho contabilístico.
5. **Consultar a ficha e o histórico** com totais de quantidades por unidade e tempos por recurso, separando declarado de verificado. Custos, preços e margens não são calculados nesta entrega. O modelo reserva ligações futuras de valorização, sem preencher custo ausente com zero.
Cada acontecimento conserva ID estável, versão, empresa/unidade, ordem/tarefa, recurso, quantidade/unidade, data efectiva, data de registo, autor, fonte/evidência e estado de verificação. Correcções referem o original por nova versão/compensação, sem apagar a origem. Futuro custeio referencia `origem + versão + parcela`, não uma cópia física do consumo.
**Fora deste primeiro recorte:** orçamento completo, garantia/contrato automatizados, revisão comercial/preços, aceite/entrega completos, conversão para prestação/rascunho, linhas materiais de factura, stock/compras, custo industrial, imputação de indirectos, margem, produção, emissão e recebimentos. Essas partes permanecem na visão das secções anteriores; não são dependências ocultas da captura inicial.
Prova mínima futura: uma ordem/equipamento, um técnico, material com 3 UN entregues, 2 aplicadas e 1 devolvida; trabalho 09:00–11:00 com pausa de 15 minutos, resultando em 105 minutos efectivos. Repetição/falha não duplicam ocorrências; correcção preserva original; sobreposição e acesso cruzado são recusados. Quantidades verificadas ficam prontas para ligação a custos, **sem gerar um cálculo de custo ou produção**. Demonstração proposta exclusivamente sintética em `sgf_tempo_test`.
Decisões restantes para preparar execução: designar funções/âmbitos de abertura, apontamento e verificação; definir evidência mínima e política de autorização do trabalho; confirmar se a referência simples de material e apontamento manual bastam ao primeiro piloto. Não é necessário escolher agora método industrial, tarifas, margem, regra fiscal ou linha comercial de material. Nenhum acesso é atribuído por este registo; migração/concessões futuras serão apresentadas concretamente antes da aplicação conforme o âmbito que vier a ser autorizado.
### Responsabilidades e autorização — proposta posterior do utilizador
Em 27/10/2026, o utilizador propôs o seguinte desenho para este recorte. Esta especificação incorpora a proposta, sem a converter em concessões ou autorização de implementação:
| Função | Responsabilidade delimitada | Titular / estado |
| --- | --- | --- |
| Abertura e atribuição | Abrir a ordem, atribuir responsável e registar evidência da autorização do cliente | Osmane, conforme proposta do utilizador; não significa acesso já concedido |
| Técnico responsável | Registar tarefas, tempos e materiais das ordens atribuídas | Pessoa real ainda não indicada; identidade sintética própria nos futuros testes |
| Verificação técnica dos apontamentos | Verificar/recusar versão exacta dos registos, com motivo e histórico | Outra pessoa autorizada, ainda não indicada; identidade sintética distinta nos futuros testes |
Samira não recebe supervisão técnica pela sua função contabilística/fiscal ou pelos direitos de recebimentos. A verificação exige identidade diferente do autor do apontamento; atribuir uma ordem não concede automaticamente capacidade de verificar. Todas as funções continuam limitadas à empresa/unidade e às ordens autorizadas, sem custos/margens ou administração implícitos.
**Evidência mínima de autorização:** quem autorizou e a qualidade em que representa o cliente, data, canal, trabalho abrangido e referência ao comprovativo ou contrato. Ligar ao conteúdo/versão da proposta ou descrição de trabalho revista. O operador que regista a evidência e a pessoa do cliente que autorizou ficam identificados separadamente. A referência isolada a um contrato não prova que o trabalho concreto está coberto.
Abrir a ordem permite preparar/atribuir o pedido, mas **não autoriza execução**. Trabalho sem cobertura demonstrada mantém pendência; cada alteração ao alcance aprovado conserva autor, data, motivo e nova versão, com autorização complementar quando necessária. Não ampliar silenciosamente a autorização anterior nem apagar o âmbito que já foi executado.
É possível preparar o desenho e, após autorização de implementação, provar o percurso com funções de abertura, execução e verificação sintéticas distintas, sem escolher titulares reais ou lhes atribuir direitos. Nomes/contas reais do técnico e do verificador permanecem pendentes apenas para aplicação real; não se pede essa identificação como condição de concluir esta especificação. A aceitação de recebimentos permanece independente.
## 14. Execução técnica posterior e ocorrência já acontecida
Em 27/10/2026, o utilizador autorizou expressamente executar código e testes do primeiro recorte e acrescentou que bloquear início não pode impedir relatar ocorrência passada para auditoria. Essa ordem substitui as restrições anteriores de etapa apenas documental **somente no âmbito sintético delimitado**. Resultado e limites em [WORKSHOP_RESOURCE_DELIVERY.md](WORKSHOP_RESOURCE_DELIVERY.md).
O comando de início exige autorização compatível. O relato retrospectivo conserva autor, data, tarefa/versão, origem/evidência e marca de fora do âmbito quando não havia cobertura demonstrada. Nova autorização não reescreve esse relato. Verificador distinto pode reconhecer o facto para auditoria ou recusar; não o promove automaticamente a execução autorizada ou apontamento verificado. Correcção mantém história e exige nova decisão.
Migração `080000` em desenvolvimento, concessões reais e activação operacional continuam pendentes de autorizações específicas. Não criar técnicos/verificadores reais por suposição. Não altera a aceitação separada de recebimentos, os bloqueios fiscais ou as extensões documentais de custos industriais. Entrega técnica não constitui aceitação do utilizador.
+22
View File
@@ -0,0 +1,22 @@
# Aceitação — WORKSHOP-RESOURCE-RECORD-v1
Em 27/10/2026 (Africa/Johannesburg), o utilizador declarou:
> Aceito a entrega isolada WORKSHOP-RESOURCE-RECORD-v1. Regista a aceitação e cria o checkpoint local, excluindo credenciais e dados reais. Não apliques ainda a migração 080000 nem concessões. Mantém a 070000 e a aceitação de recebimentos separadas.
Aceitação limitada à entrega e provas descritas em [WORKSHOP_RESOURCE_DELIVERY.md](WORKSHOP_RESOURCE_DELIVERY.md): ordem, atribuição, autorização, tarefas, tempos, materiais e verificação por identidade distinta, exclusivamente no ambiente sintético isolado. O início não autorizado é recusado, mas uma ocorrência passada pode ser relatada para auditoria, permanentemente sinalizada, sem autorização retroactiva ou validação automática.
Preservam-se os resultados existentes: 41 testes / 832 asserções, seis casos com processos concorrentes, compilação e prova de login real no navegador isolado. A instabilidade observada numa preparação sintética anterior de recebimentos continua documentada; esta aceitação não a apaga nem declara aceite a entrega de recebimentos. Não foram repetidos testes por uma alteração exclusivamente documental.
## Limites preservados
- Migração `080000` não aplicada em desenvolvimento; quatro direitos propostos para abertura por Osmane não concedidos.
- Técnico e verificador reais por identificar; nenhuma conta ou concessão presumida, incluindo supervisão técnica pela Samira.
- Migração `070000` e aceitação de `SGF-RECEIVABLES-SIM-v1` separadas e pendentes.
- Bloqueios operacionais, de emissão e configuração reais mantidos; sem stock, custos industriais, facturação, recebimentos reais, lançamentos ou publicação.
## Checkpoint local
O commit que contém este registo é o checkpoint desta aceitação, sucessor de `4276aca`. O seu identificador é apresentado na entrega; não se insere o próprio hash no ficheiro que ele identifica.
Preserva também código e documentação posteriores já existentes de recebimentos e integrações partilhadas, para manter uma árvore coerente, sem lhes atribuir aceitação nem executar as migrações. Fontes, testes e provas sintéticas são versionáveis; credenciais, ambientes, dados operacionais reais e relatórios locais de preservação da base normal ficam excluídos. Nenhum push ou publicação é autorizado.
+118
View File
@@ -0,0 +1,118 @@
# Oficina — entrega técnica isolada de ordem e recursos
27/10/2026, Africa/Johannesburg. **WORKSHOP-RESOURCE-RECORD-v1**, conforme [âmbito, secção 13](WORKSHOP_MANAGEMENT_SCOPE.md#13-primeiro-recorte-coordenado--ordem-e-recursos) e precisão posterior sobre ocorrências já acontecidas. Entrega técnica para revisão; **não significa aceitação do utilizador**. Preservados `4276aca`, alterações posteriores e recebimentos, cuja aceitação continua separada. Sem novo checkpoint.
## Funcional
- Abertura de ordem com cliente existente e um equipamento individual, localização, pedido, evidência e referência operacional única. Equipamento pode ser reutilizado por ID, sem repetir cadastro por ordem.
- Atribuição explícita de técnico com pertença e direitos próprios. Abertura, atribuição e autorização são comandos separados: abrir não autoriza executar. Nenhuma supervisão técnica foi atribuída à Samira.
- Tarefas versionadas com origem estável. Serviço existente pode ser referenciado quando compatível; não criar serviço fictício, material/stock ou ordem de produção.
- Autorização ligada à versão exacta da tarefa: pessoa que autorizou, qualidade/representação, data, canal, alcance e comprovativo/contrato, separada do operador que registou. Não se declara que a referência comprova assinatura digital ou validade externa.
- Início de trabalho exige autorização já registada e compatível. Outra tarefa/versão exige revisão explícita. Autorizações e âmbitos anteriores não são reescritos.
- Relato retrospectivo de tempo/material é permitido mesmo sem autorização. O servidor/base marca consumo/execução fora do âmbito; não transforma o relato em autorização ou confirmação automática. Autorização posterior não altera o registo histórico. Uma correcção de ocorrência já sinalizada conserva a excepção.
- Verificação/recusa referem a versão exacta, por identidade distinta do autor. Ocorrência fora do âmbito não pode receber `verified`; admite `acknowledged` (facto reconhecido apenas para auditoria) ou `refused`. Reconhecimento não acrescenta tempo/aplicação verificados nem remove a marca de excepção.
- Tempos com início/fim, zona, pausas, trabalho/deslocação e minutos efectivos exactos. Pausas inválidas, duração negativa/futura/subminuto não representável e sobreposição do mesmo técnico são recusadas. Técnicos diferentes podem ter intervalos simultâneos.
- Materiais com identidade de recurso, unidade, propriedade empresa/cliente, origem, quantidade e ocorrência distinta: requerido, entregue, aplicado/consumido, devolvido. Reserva não é uma opção suportada. Requisição/entrega/devolução não constituem por si autorização de execução ou consumo.
- Consulta da ficha, versões, decisões, totais relatados versus verificados e diferenças físicas a explicar. Sem custos, preços, margem, facturação, recibo ou lançamento.
## Origem, versões e integridade
Cada apontamento conserva ID, ordem, tipo, origem, versão, predecessor, tarefa/autorização/início quando aplicáveis, técnico, quantidades/unidade ou tempos, datas, autor e evidência. A identidade de origem por ordem/tipo é estável; novas versões são explícitas e não eliminam originais. A verificação antiga permanece associada à versão antiga e não verifica a nova.
Futuro custeio pode referenciar **ID/versão de origem**, sem criar outra ocorrência de consumo ou hora. Não foi implementado o consumidor industrial nem um repositório universal de recursos. Os eventos de oficina têm schema/causação próprios e não são reclassificados como produção ou acontecimentos financeiros.
Comando, ordem/apontamento/decisão, auditoria, evento e outbox são transaccionais. Repetição da mesma chave e conteúdo recupera IDs; conteúdo diferente entra em conflito. Uma chave nova não contorna a unicidade de referência/origem. Há testes de rollback de abertura (incluindo equipamento) e de apontamento quando falha o outbox, e replay após resposta perdida.
SQL: FKs e triggers validam empresa/cliente/equipamento, versão/ordem/tarefa/autorização, atribuição, minutos/pausas, sobreposição, propriedade/unidade de material, autor e decisão. Todas as sete tabelas são append-only, também contra UPDATE/DELETE em massa. Escritores são serializados por linha da empresa; a consulta usa a mesma ordem de bloqueio para uma versão coerente. Não se apresenta este mecanismo como RLS ou capacidade de escala de produção. SQL administrativo continua uma fronteira de confiança distinta da identidade autenticada na aplicação.
Sessão, CSRF, pertenças, unidade, direito específico e atribuição são revalidados no servidor e dentro do comando. Técnicos vêem apenas ordens actualmente atribuídas; abertura/verificação têm consulta delimitada à unidade autorizada. Listas de clientes são limitadas às ordens visíveis salvo função de abertura; lista de técnicos apenas para quem atribui. Revogar acesso tem efeito no pedido seguinte, inclusive em replay.
## Bloqueio operacional
`WorkshopWorkflow::guard()` exige `testing`/`browser-testing` e `TestingDatabaseGuard` confirma `sgf_tempo_test`, cluster próprio 127.0.0.1:55432, utilizador de testes e ligação efectiva. A consulta normal apresenta bloqueio/listas vazias para quem tiver direito, sem depender de as tabelas novas existirem. Não há flag para activar utilização real.
Os INSERT nas tabelas `wo_*` têm também guarda SQL de base `sgf_tempo_test` e porta 55432. Não se criaram ordens, equipamentos, contas ou concessões reais. `ServiceInvoicing::guard()`, cálculo fiscal, B1 e recebimentos não foram alterados. Sem acesso ao InvesteMoz, alterações globais ou dependências novas.
## Demonstração no navegador e provas
Edge, `http://127.0.0.1:8001`, sessão/cache isolados e CSRF activo. Login real com três identidades artificiais distintas; não se usou `actingAs` como prova de login. Credenciais aleatórias ficam no ficheiro local protegido do preparador, nunca neste relatório.
1. **Abertura:** gestor sintético entrou em Empresa Horizonte — Demonstração / Unidade Central — Demonstração / **Oficina de teste**. Abriu `OT-BROWSER-ABERTA` com cliente/equipamento/pedido/evidência, sem autorização implícita.
2. **Origem do caso principal:** a fixture preparou `OT-TEST-PRINCIPAL`, atribuição, tarefa e autorização pelos comandos de negócio. Usou relógio artificial apenas no processo CLI, no dia anterior às 08:00; restaurou-o no fim. Não alterou relógio do sistema, servidor normal ou política fiscal. Não pré-criou tempos/materiais.
3. **Técnico:** login separado, só ordem atribuída visível; relato de 09:00–11:00 no dia da fixture, pausa 10:00–10:15 → **105 MIN**. Depois registou material `CABO-TEST`: **3 UN entregues, 2 aplicadas e 1 devolvida**, com três origens distintas. Não foram movimentos de stock.
4. **Âmbito:** acrescentou tarefa ainda não autorizada. **Iniciar trabalho autorizado** foi recusado. Apesar disso, conseguiu relatar uma ocorrência material passada para essa tarefa; ficou **FORA DO ÂMBITO AUTORIZADO**, com origem e diferença física pendente.
5. **Verificador:** terceiro login, sem registo técnico/abertura. Verificou tarefa, tempo e materiais principais. Totais: **105 MIN verificados**, aplicação verificada **2 UN**. Reconheceu a ocorrência fora do âmbito apenas para auditoria; aplicação verificada dessa ocorrência continua zero.
6. **Autorização posterior:** gestor registou uma autorização específica adicional. A ocorrência das 11:00 continuou fora do âmbito e a decisão de auditoria não mudou; nada ganhou cobertura retroactiva.
7. **Recusa:** técnico registou `TEST-REVIEW-REFUSAL`; verificador recusou com fundamento «Descrição insuficiente; corrigir o apontamento antes de o verificar». Original e decisão preservados. Correcções, versões antigas e recuperação são cobertas também por testes e processos concorrentes.
[Prova completa no navegador](proofs/workshop-browser.png), [resumo visual](proofs/workshop-browser-summary.png), [resultados conferidos](proofs/workshop-browser-results.json) e [concorrência](proofs/workshop-concurrency.json).
O caso adicional sem entrega documentada mostra diferença observada de −1 UN e pendência, não saldo negativo de armazém ou material automaticamente disponibilizado. Somas são dos apontamentos registados; ausência de ocorrência de entrega não prova que a quantidade física real foi zero. A verificação reconhece evidência de teste, não certificação externa.
## Testes, concorrência e compilação
Resultados finais em [workshop-tests.json](proofs/workshop-tests.json). Filtro relevante: `WorkshopTest|ServiceReceivablesTest|InternalConfirmationTest|DatabaseSafetyTest`. A regressão inclui recebimentos, preservando a sua aceitação separada; não repete toda a investigação fiscal ou suites sem relação.
Resultado final: **41 testes, 832 asserções, zero falhas/erros**. Oficinas isoladamente: **11 testes, 243 asserções**. Compilação aprovada com Vite 8.3.5, sem novas dependências.
Numa execução anterior da regressão houve uma recusa de hash de revisão desactualizada na preparação de factura sintética de `ReceivablesFixture`, antes do cenário de recebimentos. A repetição com o mesmo código passou integralmente; a causa da intermitência não foi demonstrada. Regista-se esta limitação sem afrouxar a guarda de revisão ou alterar o núcleo fiscal.
Cobertura: início sem autorização, relato retrospectivo auditável, autorização posterior e alteração de tarefa/versão, auto-verificação, recusa e nova versão, imutabilidade SQL, valores/unidades/pausas inválidos, sobreposição individual e simultaneidade de técnicos distintos, contexto antigo, empresa irmã/unidade não autorizada, revogação, destino de teste incorrecto, ambiente normal/CLI, idempotência, falhas de outbox e recuperação sem duplicação.
Seis provas com dois processos realmente sobrepostos:
- Mesma abertura → uma ordem e um replay.
- Mesmo apontamento → uma inserção e um replay.
- Tempos incompatíveis do mesmo técnico → um aceite e um conflito.
- Mesma verificação → uma decisão e um replay.
- Nova versão versus decisão sobre a antiga → uma revisão vencedora e recusa da decisão antiga.
- INSERT SQL em ordens diferentes, com o mesmo técnico/intervalo → guarda de sobreposição recusa o segundo, sem depender apenas da revisão da mesma ordem.
A prova SQL insere dados deliberadamente por via administrativa para testar invariantes; não representa comandos de aplicação auditados. Todos os casos são sintéticos. A instância do navegador é parada antes dos testes finais, que recriam só a base de testes; imagens/resultados preservam a prova e os IDs não são fixos numa repetição.
## Repetição local isolada
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test '--filter=WorkshopTest|ServiceReceivablesTest|InternalConfirmationTest|DatabaseSafetyTest'
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 start
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/workshop-fixture.php --env=testing
```
No navegador seleccionar o contexto acima e **Oficina de teste**. Usar os utilizadores sintéticos `gestor@example.test`, `tecnico-oficina@example.test` e `verificador-oficina@example.test`, com credencial gerada no preparador protegido. Não usar contas reais ou revelar credenciais. A data da fixture está no manifesto local; preencher o intervalo do dia anterior como indicado na prova.
Para concorrência, executar `sgf.ps1 php scripts/workshop-concurrency.php --env=testing`. Para conferir os resultados depois de repetir o percurso descrito, `sgf.ps1 php scripts/workshop-proof.php --env=testing`. Os preparadores recusam estado já preenchido e não apagam dados para prosseguir. Terminar a instância com `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 stop` antes de testes destrutivos. Portas ocupadas não autorizam parar processos alheios.
## Migração em desenvolvimento — delta proposto, NÃO aplicado
**`2026_10_27_080000_create_workshop_resource_records.php`**, SQL em `database/sql/workshop.sql`, confirmado **Pending** por leitura de `migrate:status` em `sgf_tempo_dev`.
Cria sete tabelas: `wo_equipment` (identidade individual), `wo_orders` (abertura), `wo_entries` (atribuições/tarefas/autorizações/inícios/tempos/materiais versionados), `wo_decisions` (verificações), `wo_commands` (idempotência), `wo_events` e `wo_outbox`. Acrescenta FKs, índices e funções/triggers próprios de integridade, ambiente e imutabilidade. Amplia o CHECK de permissões com seis nomes `workshop.*` exclusivamente de unidade, preservando alternativas existentes. Não atribui direitos ou altera tabelas operacionais anteriores. Não há `down()` destrutivo.
**Só aplicar em `sgf_tempo_dev` após confirmação específica**, com proprietário separado e comparação de integridade antes/depois; sem reset/fresh. Mesmo instalada, a guarda SQL e de aplicação continua a impedir uso operacional nessa base.
**Atenção ao âmbito do comando futuro:** `070000` de recebimentos também está pendente e não foi autorizada nesta tarefa. Não executar uma migração geral que aplique ambas por arrasto. Uma eventual autorização apenas de oficina exige execução dirigida ao ficheiro `080000`, com o proprietário de migração e destino conferidos. Esta entrega não modificou o wrapper ou executou esse comando.
## Permissões propostas — separadas, nenhuma concedida
Pré-visualização read-only disponível em `scripts/workshop-permissions-preview.php`, sem modo de aplicação. Conferência em 27/10/2026: conta #1 Osmane Nala, nala@tempo.co.mz; tenant #1, Tempo Lda #1, SEGI #1. Nenhum dos quatro direitos abaixo estava atribuído.
| Função / titular | Proposta, sempre na unidade autorizada | Aplicação real |
| --- | --- | --- |
| Abertura — Osmane #1, Tempo Lda #1 / SEGI #1 | `workshop.view`, `workshop.open`, `workshop.assign`, `workshop.authorize` | Quatro concessões propostas; confirmação específica antes de aplicar |
| Técnico responsável | `workshop.view`, `workshop.record`, condicionados à atribuição actual | Conta real ainda não identificada; nenhuma conta criada ou proposta nominal inventada |
| Verificador distinto do autor | `workshop.view`, `workshop.verify` | Conta real ainda não identificada; não atribuir à Samira por consequência das suas funções actuais |
Não incluem gestão de acessos, custos/margens, stock, fiscalidade ou emissão. Técnicos/verificadores sintéticos foram criados só na base de testes com esta matriz explícita. Conceder os direitos no futuro não retira o bloqueio operacional; activação real exige outro âmbito específico.
## Aceitação posterior
Entrega isolada aceite expressamente pelo utilizador em 27/10/2026. Ver [registo de aceitação e limites do checkpoint](WORKSHOP_RESOURCE_ACCEPTANCE.md). A aceitação não aplica migrações ou concessões, não activa uso operacional e não abrange a aceitação de recebimentos. A referência anterior a ausência de aceitação presumida descreve correctamente a entrega antes desta confirmação.
## Ficheiros e limites
Principais alterações: `app/Modules/Workshop/WorkshopWorkflow.php`, `Http/WorkshopController.php`, `resources/js/WorkshopWorkspace.jsx`, integração em `resources/js/app.jsx` e `routes/web.php`; migração/SQL acima; `tests/Support/WorkshopFixture.php`, `tests/Feature/WorkshopTest.php`; scripts `workshop-fixture.php`, `workshop-worker.php`, `workshop-concurrency.php`, `workshop-proof.php` e `workshop-permissions-preview.php`; documentação/provas desta entrega.
Limites: um cliente/equipamento por ordem, tarefas simples e autorização por versão de tarefa; tempos completos em minutos, sem apontamentos subminuto ou timer geral; UI recebe uma pausa, API várias; conservadoramente, todo o intervalo individual está ocupado, mesmo durante pausa. Outro técnico pode trabalhar em simultâneo. Correcções de tarefa/tempo/material pelo próprio autor actualmente atribuído; não há edição histórica por supervisor ou transferência silenciosa de autoria. Interface inicial abre equipamento novo; reutilização por ID suportada na API, sem editor completo de cadastro.
Não há stock, disponibilidade, valorização, custos industriais, tarifas, horas facturáveis, orçamento/contrato automatizado, aceite/entrega completos, assinatura digital, conversão comercial, factura, recebimento, receita, lançamento ou fecho. Dados/evidências são declarados pelo operador; a aplicação não comprova externamente a representação do cliente ou a autenticidade do documento. Pendências reais e conformidade permanecem como estavam. Não houve publicação ou aceitação presumida.
@@ -0,0 +1,37 @@
{
"database": "sgf_tempo_test",
"simulation_only": true,
"policy": "SGF-RECEIVABLES-SIM-v1",
"notice": "TESTE \u2014 SEM VALIDADE FISCAL",
"document_id": 16,
"obligation_id": 43,
"original": "1000.00",
"received": "1000.00",
"allocated": "1000.00",
"unallocated": "0.00",
"balance": "0.00",
"receipts": [
{
"id": 44,
"amount": "400.00",
"state": "confirmed",
"allocated": "400.00",
"available": "0.00",
"reconciled": true
},
{
"id": 45,
"amount": "600.00",
"state": "confirmed",
"allocated": "600.00",
"available": "0.00",
"reconciled": true
}
],
"revision": 7,
"real_login_browser": true,
"csrf_enabled": true,
"actors_distinct": true,
"durable_events_delivered": 47,
"no_fiscal_receipt_or_new_pdf": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"database": "sgf_tempo_test",
"overlapping_processes_per_case": 2,
"checks": {
"same_confirmation_one_effect_one_replay": true,
"document_400_plus_700_one_applied_one_conflict": true,
"same_receipt_available_not_double_spent": true,
"raw_sql_concurrent_aggregate_guard": true,
"reversal_reconciliation_stale_decision_refused": true,
"concurrent_corrections_exact_revision": true
},
"real_operations": false
}
+18
View File
@@ -0,0 +1,18 @@
{
"policy": "SGF-RECEIVABLES-SIM-v1",
"date": "2026-10-27",
"database": "sgf_tempo_test",
"filter": "ServiceReceivablesTest|ServiceInvoiceTest|InternalConfirmationTest|DatabaseSafetyTest",
"tests": 39,
"assertions": 711,
"failures": 0,
"errors": 0,
"junit_local_ignored": "storage/app/receivables-tests.xml",
"build": { "command": "scripts/sgf.ps1 npm run build", "vite": "8.3.5", "passed": true },
"source_guards_and_fiscal_core_unchanged_from": "4276aca",
"development_migration_070000_applied": false,
"real_receivables_grants_applied": false,
"real_issuance_block_preserved": true,
"browser_server_stopped_before_final_tests": true,
"user_acceptance": false
}
+29
View File
@@ -0,0 +1,29 @@
{
"database": "sgf_tempo_test",
"policy": "WORKSHOP-RESOURCE-RECORD-v1",
"synthetic_only": true,
"real_browser_login": true,
"csrf_enabled": true,
"three_distinct_actors": true,
"order_id": 13,
"minutes_reported": 105,
"minutes_verified": 105,
"material": {
"resource": "CABO-TEST",
"unit": "UN",
"owner": "company",
"required": "0",
"delivered": "3",
"consumed": "2",
"returned": "1",
"verified_consumed": "2",
"unexplained": "0",
"status": "balanced_observations"
},
"unauthorized_start_refused_in_browser": true,
"past_exception_preserved_after_later_authorization": true,
"exception_decision": "acknowledged_not_authorized",
"refused_entry": 60,
"refusal_reason": "Descrição insuficiente; corrigir o apontamento antes de o verificar.",
"real_operations_enabled": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"database": "sgf_tempo_test",
"overlapping_processes_per_case": 2,
"checks": {
"same_opening_one_order_one_replay": true,
"same_record_one_insert_one_replay": true,
"concurrent_same_technician_not_double_counted": true,
"verification_one_decision_one_replay": true,
"new_version_invalidates_old_review": true,
"raw_sql_revision_overlap_guard": true
},
"synthetic_only": true
}
+27
View File
@@ -0,0 +1,27 @@
{
"policy": "WORKSHOP-RESOURCE-RECORD-v1",
"date": "2026-10-27",
"database": "sgf_tempo_test",
"filter": "WorkshopTest|ServiceReceivablesTest|InternalConfirmationTest|DatabaseSafetyTest",
"tests": 41,
"assertions": 832,
"failures": 0,
"errors": 0,
"workshop_only": { "tests": 11, "assertions": 243, "passed": true },
"overlapping_process_cases": 6,
"junit_local_ignored": "storage/app/workshop-tests.xml",
"build": { "command": "scripts/sgf.ps1 npm run build", "vite": "8.3.5", "passed": true },
"prior_regression_attempt": {
"errors": 1,
"failures": 0,
"location": "ReceivablesFixture synthetic invoice review/issue",
"message": "Review hash rejected as stale",
"resolution": "Not reproduced on unchanged-code rerun; cause unconfirmed; fiscal guards not relaxed"
},
"development_migration_080000_applied": false,
"development_migration_070000_applied": false,
"real_workshop_grants_applied": false,
"real_operational_and_issuance_blocks_preserved": true,
"browser_server_stopped_before_final_tests": true,
"user_acceptance": false
}
@@ -0,0 +1,53 @@
import React, { useEffect, useRef, useState } from 'react';
const titles = { obligation:'Obrigação de teste', receipt:'Recebimento proposto', observation:'Observação simulada', correction:'Correcção proposta' };
const actions = { reverse_allocation:'Reverter afectação integral', reverse_reconcile:'Desfazer conferência', reverse_receipt:'Reverter recebimento integral', reduction:'Aplicar redução sintética', due:'Corrigir vencimento' };
const effects = { open:'Obrigação aberta', receipt_confirm:'Recebimento confirmado', refused:'Proposta recusada', allocate:'Afectação aplicada', reconcile:'Reconciliado em simulação', divergence:'Divergência preservada', reverse_allocation:'Afectação revertida', reverse_reconcile:'Conferência desfeita', reverse_receipt:'Recebimento revertido', reduction:'Redução sintética aplicada', due:'Vencimento corrigido' };
const money = v => String(v ?? '').replace('.', ',');
export default function ServiceReceivablesWorkspace({context, can, api, busy}) {
const [data,setData]=useState(null), [error,setError]=useState(''), [working,setWorking]=useState(false), [obId,setObId]=useState(''), [proof,setProof]=useState(null);
const attempt=useRef(null);
const proposalIntent=useRef(null);
const allowed = r => can(`service_receivables.${r}`,context.unit?.id ?? null);
const load = async () => { const next=await api('/api/service-receivables'); setData(next); return next; };
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
const ob=data?.obligations.find(o=>String(o.id)===String(obId)) ?? data?.obligations[0];
const record=id=>data?.records.find(r=>r.id===Number(id));
const origin=ob && record(ob.id);
const receipts=ob ? Object.values(ob.receipts) : [];
const disabled=busy||working;
async function send(path,input) {
setError('');setWorking(true);
const canonical=JSON.stringify([path,input]);
if(attempt.current?.canonical!==canonical) attempt.current={canonical,key:crypto.randomUUID()};
try { await api(`/api/service-receivables${path}`,'POST',input,context.token,attempt.current.key); await load(); attempt.current=null; proposalIntent.current=null; }
catch(e) { setError(e.message); }
finally {setWorking(false);}
}
function propose(kind,values) {
const invoice=kind==='obligation'?Number(values.invoice_id):origin.invoice_id;
const signature=JSON.stringify([kind,invoice,values]);
if(proposalIntent.current?.signature!==signature) proposalIntent.current={signature,id:crypto.randomUUID()};
return send('/proposals',{kind,invoice_id:invoice,currency:'MZN',source:'TEST-MANUAL',external_id:values.external_id || proposalIntent.current.id,revision:kind==='obligation'?0:ob.revision,...(kind==='obligation'?{}:{obligation_id:ob.id}),...values});
}
const pending = r => !ob?.effects.some(e=>e.record_id===r.id && e.kind!=='allocate');
return <>
<section className="panel"><p className="eyebrow">SGF-RECEIVABLES-SIM-v1</p><h1>Contas a receber — simulação</h1><div className="alert error"><strong>TESTE — SEM VALIDADE FISCAL</strong><p>SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO</p></div><p>Liquidar um documento e conferir um recebimento são operações distintas. Sem recibo fiscal, PDF novo, banco ou contabilidade.</p>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar dados explicitamente</button><p className="help">A vista conserva a revisão apresentada. Alterações entretanto feitas por outro utilizador provocam recusa e exigem actualização; os formulários não substituem valores silenciosamente.</p></section>
{!data ? <p>A carregar…</p> : data.blocked ? <section className="panel"><h2>Percurso real indisponível</h2><p>Este incremento só funciona na instância isolada de testes. As permissões não retiram o bloqueio.</p></section> : <>
{allowed('prepare')&&<section className="panel"><h2>Preparar abertura explícita</h2><SmallForm submit="Propor obrigação" disabled={disabled} onSubmit={v=>propose('obligation',{invoice_id:Number(v.invoice_id),due_date:v.due_date||null})}><label>Documento exacto<select name="invoice_id" required>{data.documents.map(d=><option key={d.id} value={d.id}>#{d.id} · {d.series_code}/{d.number}</option>)}</select></label><label>Vencimento conhecido (opcional)<input type="date" name="due_date"/></label></SmallForm></section>}
<section className="panel"><h2>Obrigações e confirmação</h2>{data.obligations.length===0&&<p>Nenhuma obrigação aberta ou proposta. Documentos e rascunhos não criam dívida automaticamente.</p>}<label>Obrigação<select value={ob?.id||''} onChange={e=>{setObId(e.target.value);setProof(null);}}>{data.obligations.map(o=><option key={o.id} value={o.id}>#{o.id} · {record(o.id)?.external_id} · {o.state==='proposed'?'Aguarda confirmação':o.state==='refused'?'Recusada':o.state==='settled'?'Liquidada':'Em aberto'}</option>)}</select></label>{ob&&<><p>Documento #{origin.invoice_id} · Cliente #{origin.client_id} · MZN · Revisão {ob.revision} · Corte {ob.cut_date}</p><div className="summary-grid">{[['Original',ob.original],['Redução sintética',ob.reduced],['Recebido confirmado',ob.received],['Afectado',ob.allocated],['Por afectar',ob.available],[ob.opened?'Saldo por receber':'Valor proposto — obrigação não aberta',ob.balance]].map(([label,value])=><section key={label}><span>{label}</span><h2>{money(value)} MZN</h2></section>)}</div><p>Vencimento: {ob.due_date||'por definir'} · {({not_open:'Obrigação ainda não aberta',unknown:'Pendência — atraso não determinado',overdue:'Saldo vencido',not_due:'Ainda não vencido',settled:'Liquidada'})[ob.due_state]}</p>{pending(origin)&&allowed('confirm')&&<Decision record={origin} disabled={disabled} decide={decision=>send(`/${origin.id}/decision`,{version:origin.version,revision:ob.revision,decision,reason:'Decisão explícita sobre proposta sintética.'})}/>}</>}</section>
{ob&&ob.opened&&<>
{allowed('prepare')&&<section className="panel"><h2>Registar recebimento proposto</h2><SmallForm submit="Propor recebimento" disabled={disabled} onSubmit={v=>propose('receipt',v)}><label>Valor recebido (MZN; ponto decimal)<input name="amount" inputMode="decimal" placeholder="400.00" required/></label><label>Data declarada<input name="date" type="date" required/></label><label>Identificador do acontecimento sintético<input name="external_id" required/></label><label>Evidência sintética<textarea name="evidence" required/></label></SmallForm></section>}
<section className="panel"><h2>Recebimentos e afectações</h2>{receipts.map(rc=>{const r=record(rc.id); return <article className="panel" key={rc.id}><h3>Recebimento #{rc.id} · {r.external_id}</h3><p>{({proposed:'Aguarda confirmação',confirmed:'Confirmado em teste',refused:'Recusado',reversed:'Revertido'})[rc.state]} · Recebido {money(rc.amount)} · Afectado {money(rc.allocated)} · Por afectar {money(rc.available)} MZN</p><p>{rc.reconciled?'Reconciliado em simulação':'Sem reconciliação simulada confirmada'}</p><button onClick={()=>setProof(rc.id)}>Consultar comprovativo interno</button>{pending(r)&&allowed('confirm')&&<Decision record={r} disabled={disabled} decide={decision=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision,reason:'Confirmação de evidência exclusivamente sintética.'})}/ >}{rc.state==='confirmed'&&allowed('allocate')&&<SmallForm submit="Afectar explicitamente" disabled={disabled} onSubmit={v=>send(`/${rc.id}/allocate`,{amount:v.amount,version:r.version,revision:ob.revision})}><label>Montante a afectar a este documento<input name="amount" inputMode="decimal" required/></label></SmallForm>}</article>;})}</section>
{proof&&<section className="panel"><h2>Comprovativo interno #{proof}</h2><strong>TESTE — SEM VALIDADE FISCAL</strong><p>SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO</p><p>Documento #{origin.invoice_id} · Recebimento #{proof} · Revisão {ob.revision} · Corte {ob.cut_date}</p><p>Evidência: {record(proof)?.data.evidence} · Data declarada: {record(proof)?.data.date}</p><p>Recebido {money(ob.receipts[proof]?.amount)} · Afectado {money(ob.receipts[proof]?.allocated)} · Por afectar {money(ob.receipts[proof]?.available)} MZN</p><p>Estado actual: {ob.receipts[proof]?.state}. Consulta interna, sem emissão ou entrega de recibo fiscal.</p>{ob.effects.filter(e=>e.receipt_id===proof||e.record_id===proof).map(e=><p key={e.id}>Efeito #{e.id} · {effects[e.kind]} · {money(e.amount)} MZN · Autor #{e.actor_id}</p>)}</section>}
{allowed('prepare')&&<section className="panel"><h2>Preparar observação de fonte simulada</h2><SmallForm submit="Guardar observação sintética" disabled={disabled} onSubmit={v=>propose('observation',{...v,receipt_id:Number(v.receipt_id)})}><label>Recebimento exacto<select name="receipt_id">{receipts.filter(r=>r.state==='confirmed').map(r=><option key={r.id} value={r.id}>#{r.id} · {record(r.id)?.external_id}</option>)}</select></label><label>Valor observado (MZN)<input name="amount" required/></label><label>Data da observação<input name="date" type="date" required/></label><label>ID da observação sintética<input name="external_id" required/></label><label>Evidência artificial<textarea name="evidence" required/></label></SmallForm></section>}
<section className="panel"><h2>Conferência das observações</h2>{data.records.filter(r=>r.kind==='observation'&&r.obligation_id===ob.id).map(r=><article key={r.id}><h3>Observação #{r.id} → Recebimento #{r.receipt_id}</h3><p>{r.external_id} · {money(r.amount)} MZN · {r.data.date}</p>{pending(r)&&allowed('reconcile')&&<button disabled={disabled} onClick={()=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision:'approved',reason:'Conferência explícita da observação simulada.'})}>Conferir correspondência exacta</button>}</article>)}</section>
{allowed('correction_request')&&<section className="panel"><h2>Propor correcção de teste</h2><SmallForm submit="Submeter correcção para validação" disabled={disabled} onSubmit={v=>{const d={action:v.action,reason:v.reason}; if(v.action==='reduction')Object.assign(d,{amount:v.amount,document_ref:v.document_ref,line:'1'}); else if(v.action==='due')d.due_date=v.due_date||null; else if(v.action==='reverse_receipt')d.receipt_id=Number(v.receipt_id); else d.target_effect_id=Number(v.target_effect_id); return propose('correction',d);}}><label>Operação<select name="action">{Object.entries(actions).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select></label><label>Efeito exacto a reverter<select name="target_effect_id">{ob.effects.filter(e=>['allocate','reconcile'].includes(e.kind)).map(e=><option key={e.id} value={e.id}>#{e.id} · {effects[e.kind]} · {money(e.amount)}</option>)}</select></label><label>Recebimento para reversão integral<select name="receipt_id">{receipts.map(r=><option key={r.id} value={r.id}>#{r.id}</option>)}</select></label><label>Valor da redução (se aplicável)<input name="amount" placeholder="100.00"/></label><label>Documento redutor sintético (se aplicável)<input name="document_ref" placeholder="TEST-REDUCTION-..."/></label><label>Novo vencimento (se aplicável)<input name="due_date" type="date"/></label><label>Motivo explícito<textarea name="reason" required/></label><p className="help">Apenas os campos da operação escolhida serão enviados. Redução refere a linha 1 do documento original.</p></SmallForm></section>}
<section className="panel"><h2>Propostas de correcção</h2>{data.records.filter(r=>r.kind==='correction'&&r.obligation_id===ob.id).map(r=><article key={r.id}><h3>#{r.id} · {actions[r.data.action]}</h3><p>{r.data.reason} · Revisão proposta {r.data.revision} · {r.data.document_ref}</p>{pending(r)&&allowed('correction_approve')&&<Decision record={r} disabled={disabled} decide={decision=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision,reason:'Decisão explícita da correcção operacional sintética.'})}/>}</article>)}</section>
<section className="panel"><h2>Histórico imutável</h2>{ob.effects.map(e=><p key={e.id}>#{e.id} · {effects[e.kind]} · {money(e.amount)} MZN · Autor #{e.actor_id} · {e.created_at}{e.target_effect_id&&` · Reverte efeito #${e.target_effect_id}`}</p>)}</section>
</>}
</>}
</>;
}
function SmallForm({children,onSubmit,submit,disabled}) {return <form onSubmit={e=>{e.preventDefault();onSubmit(Object.fromEntries(new FormData(e.currentTarget)));}}>{children}<button className="primary" disabled={disabled}>{submit}</button></form>;}
function Decision({disabled,decide}) {return <div className="actions"><button className="primary" disabled={disabled} onClick={()=>decide('approved')}>Confirmar proposta</button><button disabled={disabled} onClick={()=>decide('refused')}>Recusar proposta</button></div>;}
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useRef, useState } from 'react';
const kindLabels={assignment:'Responsável atribuído',task:'Tarefa',authorization:'Autorização registada',start:'Início autorizado',time:'Tempo relatado',material:'Material observado'};
const eventLabels={required:'Requisitado',delivered:'Entregue',consumed:'Aplicado/consumido',returned:'Devolvido'};
const decisionLabels={verified:'Verificado',refused:'Recusado',acknowledged:'Ocorrência reconhecida para auditoria — continua fora do âmbito'};
const iso=v=>new Date(v).toISOString();
export default function WorkshopWorkspace({context,can,api,busy}) {
const [data,setData]=useState(null),[orderId,setOrderId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
const attempt=useRef(null);
const allowed=r=>can(`workshop.${r}`,context.unit?.id??null);
const load=async()=>{const d=await api('/api/workshop');setData(d);return d;};
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
const order=data?.orders.find(o=>String(o.id)===String(orderId))??data?.orders[0];
const tasks=order?.entries.filter(e=>e.kind==='task'&&e.current)??[];
const task=tasks.find(e=>String(e.id)===String(taskId))??tasks[0];
const authorizations=order?.entries.filter(e=>e.kind==='authorization'&&e.current&&e.task_id===task?.id)??[];
const auth=authorizations.at(-1);
const starts=order?.entries.filter(e=>e.kind==='start'&&!order.entries.some(t=>t.start_id===e.id))??[];
const disabled=busy||working;
async function send(path,input) {
setWorking(true);setError(''); const canonical=JSON.stringify([path,input]);
if(attempt.current?.canonical!==canonical)attempt.current={canonical,key:crypto.randomUUID()};
try{await api('/api/workshop'+path,'POST',input,context.token,attempt.current.key);await load();attempt.current=null;}
catch(e){setError(e.message);}finally{setWorking(false);}
}
function entry(kind,values) {
const d={...values};
if(d.previous_id) {
const previous=order.entries.find(e=>e.id===Number(d.previous_id));
if(!previous){setError('Seleccione uma origem existente desta ordem.');return;}
d.previous_id=previous.id; d.origin=previous.origin; d.version=previous.version+1;
} else {delete d.previous_id;d.version=1;}
return send(`/orders/${order.id}/entries`,{kind,revision:order.revision,...d});
}
const correctionFields=kind=><><label>Versão anterior a corrigir (opcional)<select name="previous_id"><option value="">Novo acontecimento</option>{order.entries.filter(e=>e.kind===kind&&e.current).map(e=><option key={e.id} value={e.id}>#{e.id} · {e.origin} v{e.version}</option>)}</select></label><label>Motivo de correcção (se aplicável)<input name="correction_reason"/></label></>;
return <>
<section className="panel"><p className="eyebrow">WORKSHOP-RESOURCE-RECORD-v1</p><h1>Oficina — registo de recursos</h1><div className="alert error"><strong>TESTE — SEM UTILIZAÇÃO OPERACIONAL</strong><p>Sem stock, custos, facturação ou recebimentos.</p></div>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar revisão explicitamente</button><p className="help">Abertura não autoriza trabalho. Relatar uma ocorrência passada não lhe dá autorização retroactiva. Alterações concorrentes exigem nova revisão.</p></section>
{!data?<p>A carregar…</p>:data.blocked?<section className="panel"><h2>Utilização operacional bloqueada</h2><p>Disponível apenas na instância isolada ligada à base de testes.</p></section>:<>
{allowed('open')&&<section className="panel"><h2>Abrir ordem de teste</h2><Form label="Abrir ordem" disabled={disabled} save={v=>send('/orders',{...v,client_id:Number(v.client_id)})}><label>Referência operacional<input name="reference" required/></label><label>Cliente existente<select name="client_id">{data.clients.map(c=><option key={c.id} value={c.id}>#{c.id} · {c.name}</option>)}</select></label><label>Identificação individual do equipamento<input name="equipment_identifier" required/></label><label>Descrição / marca / modelo<input name="equipment_description" required/></label><label>Número de série (se conhecido)<input name="serial"/></label><label>Pedido / avaria<textarea name="request" required/></label><label>Localização<input name="location" required/></label><label>Evidência do pedido<input name="evidence" required/></label></Form><p className="help">Para reutilizar equipamento já identificado, a API aceita o seu ID; não criar outro só por existir uma nova ordem.</p></section>}
<section className="panel"><h2>Ordens autorizadas para consulta</h2><label>Ordem<select value={order?.id??''} onChange={e=>{setOrderId(e.target.value);setTaskId('');}}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>{order?<><p>Cliente #{order.client_id} · {order.equipment.identifier} · {order.equipment.description} · {order.location}</p><p>{order.request}</p><p>Revisão {order.revision} · Responsável #{order.assigned_to??'por atribuir'} · {({open:'Aberta',in_review:'Apontamentos em revisão',exception_review:'Ocorrências fora do âmbito para revisão',records_verified:'Registos verificados — sem conclusão comercial'})[order.state]}</p><div className="summary-grid"><div><span>Tempo efectivo relatado</span><h2>{order.minutes_reported} MIN</h2></div><div><span>Tempo efectivo verificado</span><h2>{order.minutes_verified} MIN</h2></div></div></>:<p>Sem ordens visíveis neste âmbito/atribuição.</p>}</section>
{order&&<>
{allowed('assign')&&<section className="panel"><h2>Atribuir responsável</h2><Form label="Atribuir técnico" disabled={disabled} save={v=>entry('assignment',{origin:v.origin,technician_id:Number(v.technician_id)})}><label>Referência da atribuição<input name="origin" required/></label><label>Técnico com direito próprio<select name="technician_id">{data.technicians.map(t=><option key={t.id} value={t.id}>#{t.id} · {t.name}</option>)}</select></label></Form></section>}
<section className="panel"><h2>Tarefa e âmbito</h2><label>Versão da tarefa<select value={task?.id??''} onChange={e=>setTaskId(e.target.value)}>{tasks.map(t=><option key={t.id} value={t.id}>#{t.id} · {t.data.description} · v{t.version}</option>)}</select></label><p>{auth?`Autorização #${auth.id}: ${auth.data.authorized_by} · ${auth.data.authorized_at} · ${auth.data.scope}`:'Sem autorização demonstrada para esta versão.'}</p></section>
{allowed('record')&&<section className="panel"><h2>Registar tarefa ou nova versão</h2><Form label="Guardar tarefa" disabled={disabled} save={v=>entry('task',v)}><label>Identificador de origem da tarefa<input name="origin" required/></label><label>Descrição da tarefa<textarea name="description" required/></label>{correctionFields('task')}</Form></section>}
{allowed('authorize')&&task&&<section className="panel"><h2>Registar autorização do cliente</h2><Form label="Registar autorização" disabled={disabled} save={v=>entry('authorization',{...v,task_id:task.id,authorized_at:iso(v.authorized_at)})}><label>Identificador da evidência de autorização<input name="origin" required/></label><label>Quem autorizou<input name="authorized_by" required/></label><label>Qualidade / representação do cliente<input name="capacity" required/></label><label>Data/hora da autorização do cliente<input type="datetime-local" name="authorized_at" required/></label><label>Canal<input name="channel" required/></label><label>Trabalho abrangido nesta versão<textarea name="scope" required/></label><label>Referência do comprovativo ou contrato<input name="evidence" required/></label>{correctionFields('authorization')}</Form><p className="help">O autor deste registo é distinto da pessoa do cliente que autorizou. Nova autorização não limpa ocorrências anteriores fora do âmbito.</p></section>}
{allowed('record')&&task&&<>
<section className="panel"><h2>Início de trabalho</h2><Form label="Iniciar trabalho autorizado" disabled={disabled} save={v=>entry('start',{...v,task_id:task.id,...(auth?{authorization_id:auth.id}:{})})}><label>Referência do início<input name="origin" required/></label><label>Evidência do início<input name="evidence" required/></label></Form><p>Sem autorização compatível, o servidor recusa iniciar. Uma ocorrência passada pode ser relatada no formulário seguinte, sem se tornar autorizada.</p></section>
<section className="panel"><h2>Relatar tempo efectivo</h2><Form label="Guardar apontamento de tempo" disabled={disabled} save={v=>{if(Boolean(v.pause_start)!==Boolean(v.pause_end)){setError('Indique início e fim da pausa; não será ignorada.');return;} const d={origin:v.origin,task_id:task.id,...(auth?{authorization_id:auth.id}:{}),mode:v.start_id?'started':'reported',time_type:v.time_type,starts_at:iso(v.starts_at),ends_at:iso(v.ends_at),evidence:v.evidence,previous_id:v.previous_id,correction_reason:v.correction_reason,pauses:[]}; if(v.pause_start&&v.pause_end)d.pauses=[{start:iso(v.pause_start),end:iso(v.pause_end)}]; if(v.start_id)d.start_id=Number(v.start_id);return entry('time',d);}}><label>Identificador da ocorrência de tempo<input name="origin" required/></label><label>Início registado (opcional)<select name="start_id"><option value="">Relato de ocorrência já acontecida</option>{starts.map(s=><option key={s.id} value={s.id}>#{s.id} · {s.starts_at} · tarefa #{s.task_id}</option>)}</select></label><label>Tipo de tempo<select name="time_type"><option value="work">Trabalho</option><option value="travel">Deslocação</option></select></label><label>Início efectivo<input type="datetime-local" name="starts_at" required/></label><label>Fim efectivo<input type="datetime-local" name="ends_at" required/></label><label>Início da pausa (opcional)<input type="datetime-local" name="pause_start"/></label><label>Fim da pausa (opcional)<input type="datetime-local" name="pause_end"/></label><label>Evidência da ocorrência<textarea name="evidence" required/></label>{correctionFields('time')}</Form><p className="help">A API suporta várias pausas; este formulário inicial recebe uma. Intervalos incompletos ou não representáveis exactamente em minutos são recusados.</p></section>
<section className="panel"><h2>Registar material observado</h2><Form label="Guardar apontamento de material" disabled={disabled} save={v=>entry('material',{...v,task_id:task.id,...(auth?{authorization_id:auth.id}:{}),starts_at:iso(v.starts_at)})}><label>Identificador da ocorrência material<input name="origin" required/></label><label>Referência estável do material<input name="resource" required/></label><label>Ocorrência<select name="event">{Object.entries(eventLabels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Propriedade<select name="owner"><option value="company">Empresa</option><option value="client">Cliente</option></select></label><label>Unidade de medida<select name="unit">{['UN','KG','M','L'].map(u=><option key={u}>{u}</option>)}</select></label><label>Quantidade observada<input name="quantity" inputMode="decimal" required/></label><label>Data/hora da ocorrência<input name="starts_at" type="datetime-local" required/></label><label>Evidência de origem<textarea name="evidence" required/></label>{correctionFields('material')}</Form><p>Sem reserva, armazém, custo ou saída de stock. Divergência física fica visível para revisão.</p></section>
</>}
<section className="panel"><h2>Materiais por referência, propriedade e unidade</h2>{order.materials.map(m=><article key={m.resource+m.owner+m.unit}><h3>{m.resource} · {m.unit} · {m.owner==='client'?'Cliente':'Empresa'}</h3><p>Requisitado {m.required} · Entregue {m.delivered} · Aplicado {m.consumed} · Devolvido {m.returned} · Aplicação verificada {m.verified_consumed}</p><p>Diferença física a explicar: {m.unexplained} {m.unit}. São observações, não saldo de armazém.</p></article>)}</section>
<section className="panel"><h2>Apontamentos, versões e verificação</h2>{order.entries.map(e=><article className="panel" key={e.id}><h3>#{e.id} · {kindLabels[e.kind]} · {e.origin} v{e.version}{!e.current?' · Histórico substituído':''}</h3><p>{e.data.description||e.data.scope||e.data.evidence} · Autor #{e.actor_id}</p>{e.minutes!=null&&<p>{e.starts_at} a {e.ends_at} · {e.minutes} MIN efectivos · {e.data.time_type==='travel'?'Deslocação':'Trabalho'}</p>}{e.quantity!=null&&<p>{eventLabels[e.data.event]} · {e.quantity} {e.data.unit} · {e.data.resource}</p>}{e.outside_authority&&<div className="alert error">FORA DO ÂMBITO AUTORIZADO — relato para auditoria. Não autoriza execução nem ganha cobertura retroactiva.</div>}<p>{e.decision?`${decisionLabels[e.decision.decision]} · ${e.decision.reason} · Verificador #${e.decision.actor_id}`:e.current&&['task','time','material'].includes(e.kind)?'Aguarda verificação explícita.':''}</p>{allowed('verify')&&e.current&&!e.decision&&['task','time','material'].includes(e.kind)&&<Form label={e.outside_authority?'Reconhecer ocorrência para auditoria':'Verificar apontamento'} disabled={disabled} save={v=>send(`/entries/${e.id}/verification`,{version:e.version,revision:order.revision,decision:e.outside_authority?'acknowledged':'verified',reason:v.reason})}><label>Fundamento da revisão<input name="reason" required/></label><button type="button" disabled={disabled} onClick={event=>{const form=event.currentTarget.form;if(!form.reportValidity())return;send(`/entries/${e.id}/verification`,{version:e.version,revision:order.revision,decision:'refused',reason:String(new FormData(form).get('reason'))});}}>Recusar apontamento</button></Form>}</article>)}</section>
</>}
</>}
</>;
}
function Form({children,label,disabled,save}) {return <form onSubmit={e=>{e.preventDefault();save(Object.fromEntries(new FormData(e.currentTarget)));}}>{children}<button className="primary" disabled={disabled}>{label}</button></form>;}
+5 -1
View File
@@ -7,6 +7,8 @@ import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
@@ -51,7 +53,7 @@ function App() {
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('workshop.view',context?.unit?.id ?? null) ? [['workshop', 'Oficina de teste']] : []), ...(can('service_receivables.view',context?.unit?.id ?? null) ? [['service_receivables', 'Recebimentos simulados']] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
@@ -61,6 +63,8 @@ function App() {
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
+8
View File
@@ -15,6 +15,14 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/logout', [IdentityController::class, 'logout']);
Route::post('/api/context', [IdentityController::class, 'select']);
Route::middleware(RequireCompanyContext::class)->group(function (): void {
Route::get('/api/workshop', [\App\Modules\Workshop\Http\WorkshopController::class,'index']);
Route::post('/api/workshop/orders', [\App\Modules\Workshop\Http\WorkshopController::class,'open']);
Route::post('/api/workshop/orders/{id}/entries', [\App\Modules\Workshop\Http\WorkshopController::class,'record'])->whereNumber('id');
Route::post('/api/workshop/entries/{id}/verification', [\App\Modules\Workshop\Http\WorkshopController::class,'verify'])->whereNumber('id');
Route::get('/api/service-receivables', [\App\Modules\Services\Http\ServiceReceivablesController::class,'index']);
Route::post('/api/service-receivables/proposals', [\App\Modules\Services\Http\ServiceReceivablesController::class,'propose']);
Route::post('/api/service-receivables/{id}/decision', [\App\Modules\Services\Http\ServiceReceivablesController::class,'decide'])->whereNumber('id');
Route::post('/api/service-receivables/{id}/allocate', [\App\Modules\Services\Http\ServiceReceivablesController::class,'allocate'])->whereNumber('id');
Route::get('/api/service-invoices', [\App\Modules\Services\Http\ServiceInvoiceController::class,'index']);
Route::post('/api/service-invoices/proposals', [\App\Modules\Services\Http\ServiceInvoiceController::class,'propose']);
Route::post('/api/service-invoices/proposals/{id}/decision', [\App\Modules\Services\Http\ServiceInvoiceController::class,'decide'])->whereNumber('id');
+70
View File
@@ -0,0 +1,70 @@
<?php
use App\Modules\Services\ServiceInvoicing;
use App\Modules\Services\ServiceReceivables;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
ServiceReceivables::guard();
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
$fx=$f+$f['receivables']+['manager'=>\App\Models\User::findOrFail($f['manager_id']),'reader'=>\App\Models\User::findOrFail($f['reader_id'])];
$s=app(ServiceReceivables::class); $manager=$f['manager_id']; $reader=$f['reader_id']; $proof=[];
$select=fn($actor)=>app(CompanyContext::class)->selectFor($actor,$f['tenant'],$f['company'],$f['unit']);
$rev=fn($ob)=>DB::table('sr_effects')->where('obligation_id',$ob)->count();
$base=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit']];
$pair=function($a,$b)use($base) {
$a=new Process([PHP_BINARY,'scripts/receivables-worker.php','--env=testing'],base_path(),null,json_encode($a+$base+['pause'=>true]));
$b=new Process([PHP_BINARY,'scripts/receivables-worker.php','--env=testing'],base_path(),null,json_encode($b+$base));
$a->start(); if(!$a->waitUntil(fn($type,$buffer)=>str_contains($buffer,'LOCK_HELD'))) throw new RuntimeException('Overlap lock not established.');
$b->start(); $a->wait(); $b->wait(); $out=[];
foreach([$a,$b] as $p) { if(!$p->isSuccessful()) throw new RuntimeException('Synthetic 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;
};
$obligation=function()use(&$fx,$s,$select,$manager,$reader) {
$fx=\Tests\Support\InvoiceFixture::additionalDraft($fx,'862.07'); $select($manager); $si=app(ServiceInvoicing::class);
$review=$si->review($fx['draft'],$fx['series'],$fx['rule'],$manager);
$invoice=$si->issue($fx['draft'],$manager,['series_id'=>$fx['series'],'rule_id'=>$fx['rule'],'review_hash'=>$review['review_hash']],(string)Str::uuid())['id'];
$ob=$s->propose($manager,['kind'=>'obligation','invoice_id'=>$invoice,'currency'=>'MZN','source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'revision'=>0],(string)Str::uuid())['id'];
$select($reader); $s->decide($ob,$reader,['version'=>1,'revision'=>0,'decision'=>'approved','reason'=>'Open synthetic concurrency obligation.'],(string)Str::uuid());
return [$invoice,$ob];
};
$receipt=function($invoice,$ob,$amount,$confirm=true)use($s,$select,$manager,$reader,$rev) {
$select($manager); $id=$s->propose($manager,['kind'=>'receipt','invoice_id'=>$invoice,'obligation_id'=>$ob,'amount'=>$amount,'currency'=>'MZN','date'=>now(ServiceInvoicing::ZONE)->format('Y-m-d'),'source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'evidence'=>'Artificial concurrency evidence.','revision'=>$rev($ob)],(string)Str::uuid())['id'];
if($confirm) { $select($reader); $s->decide($id,$reader,['version'=>1,'revision'=>$rev($ob),'decision'=>'approved','reason'=>'Synthetic concurrency confirmation.'],(string)Str::uuid()); }
return $id;
};
$decision=fn($id,$ob)=>['method'=>'decide','id'=>$id,'actor'=>$reader,'key'=>(string)Str::uuid(),'input'=>['version'=>1,'revision'=>$rev($ob),'decision'=>'approved','reason'=>'Concurrent synthetic decision.']];
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'400.00',false); $a=$decision($r,$ob); $out=$pair($a,$a);
if($out[0]['status']!==200 || $out[1]['status']!==200 || !$out[1]['result']['replayed'] || $out[0]['result']['id']!==$out[1]['result']['id']) throw new RuntimeException('Confirmation replay failed.');
$proof['same_confirmation_one_effect_one_replay']=true;
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'400.00'); $t=$receipt($invoice,$ob,'700.00');
$allocate=fn($id,$amount,$ob)=>['method'=>'allocate','id'=>$id,'actor'=>$manager,'key'=>(string)Str::uuid(),'input'=>['version'=>1,'revision'=>$rev($ob),'amount'=>$amount]];
$out=$pair($allocate($r,'400.00',$ob),$allocate($t,'700.00',$ob));
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Document overspend race failed.');
$proof['document_400_plus_700_one_applied_one_conflict']=true;
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'700.00');
$out=$pair($allocate($r,'400.00',$ob),$allocate($r,'400.00',$ob));
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Receipt overspend race failed.');
$proof['same_receipt_available_not_double_spent']=true;
// Raw SQL writers share the same company lock and aggregate/version invariants.
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'700.00'); $t=$receipt($invoice,$ob,'400.00');
$sql=fn($id,$amount)=>['method'=>'sql_allocate','actor'=>$manager,'sql'=>['record_id'=>$id,'obligation_id'=>$ob,'receipt_id'=>$id,'kind'=>'allocate','amount'=>$amount,'revision'=>$rev($ob),'data'=>'{}','actor_id'=>$manager,'created_at'=>now()->toIso8601String()]];
$out=$pair($sql($r,'700.00'),$sql($t,'400.00'));
if(array_column($out,'status')!==[200,409] || !($out[1]['database_guard']??false)) throw new RuntimeException('SQL aggregate race failed.');
$proof['raw_sql_concurrent_aggregate_guard']=true;
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'400.00'); $select($manager); $af=$s->allocate($r,$manager,['version'=>1,'revision'=>$rev($ob),'amount'=>'400.00'],(string)Str::uuid())['id'];
$obs=$s->propose($manager,['kind'=>'observation','invoice_id'=>$invoice,'obligation_id'=>$ob,'receipt_id'=>$r,'amount'=>'400.00','currency'=>'MZN','date'=>now(ServiceInvoicing::ZONE)->format('Y-m-d'),'source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'evidence'=>'Artificial observation.','revision'=>$rev($ob)],(string)Str::uuid())['id'];
$corr=$s->propose($manager,['kind'=>'correction','action'=>'reverse_allocation','target_effect_id'=>$af,'invoice_id'=>$invoice,'obligation_id'=>$ob,'currency'=>'MZN','source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'reason'=>'Explicit reversal.','revision'=>$rev($ob)],(string)Str::uuid())['id'];
$out=$pair($decision($corr,$ob),$decision($obs,$ob));
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Reversal versus reconciliation race failed.');
$proof['reversal_reconciliation_stale_decision_refused']=true;
// Two incompatible reductions against exactly the same reviewed revision.
[$invoice,$ob]=$obligation(); $select($manager); $corrs=[];
foreach(['A','B'] as $ref) $corrs[]=$s->propose($manager,['kind'=>'correction','action'=>'reduction','amount'=>'100.00','document_ref'=>'TEST-REDUCTION-CONCURRENT-'.Str::uuid(),'line'=>'1','invoice_id'=>$invoice,'obligation_id'=>$ob,'currency'=>'MZN','source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'reason'=>'Synthetic reduction proposal '.$ref,'revision'=>$rev($ob)],(string)Str::uuid())['id'];
$out=$pair($decision($corrs[0],$ob),$decision($corrs[1],$ob));
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Concurrent corrections failed.');
$proof['concurrent_corrections_exact_revision']=true;
file_put_contents(base_path('docs/proofs/receivables-concurrency.json'),json_encode(['database'=>'sgf_tempo_test','overlapping_processes_per_case'=>2,'checks'=>$proof,'real_operations'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo "Six real overlapping-process scenarios passed, including raw SQL aggregate protection; sgf_tempo_test only.\n";
+11
View File
@@ -0,0 +1,11 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Services\ServiceReceivables::guard();
$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
if(\Illuminate\Support\Facades\DB::table('si_proposals')->exists() || \Illuminate\Support\Facades\DB::table('sr_records')->exists()) throw new RuntimeException('Test fixture must be empty; nothing deleted.');
$fixture=\Illuminate\Support\Facades\DB::transaction(fn()=>\Tests\Support\ReceivablesFixture::create($f+['manager'=>\App\Models\User::findOrFail($f['manager_id']),'reader'=>\App\Models\User::findOrFail($f['reader_id'])]));
$ids=array_intersect_key($fixture,array_flip(['client','service','source','draft','profile','temporal','calculation','series','rule','today','start','end','invoice']));
file_put_contents($file,json_encode($f+['receivables'=>$ids],JSON_THROW_ON_ERROR));
echo "Receivables fixture ready only in sgf_tempo_test: document 1000.00 MZN; no obligation, receipt or allocation pre-created.\n";
+16
View File
@@ -0,0 +1,16 @@
<?php
// Read-only proposal. Deliberately has no apply mode.
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('Development read-only scope refused.');
$db=\Illuminate\Support\Facades\DB::class;
$company=$db::table('companies')->where('id',1)->first(['id','tenant_id','name']);
$unit=$db::table('units')->where('id',1)->where('company_id',1)->first(['id','name']);
if(!$company || !$unit) throw new RuntimeException('Proposed scope not found; no operation performed.');
$rows=[];
foreach([1=>['view','prepare','allocate','correction_request'],2=>['view','confirm','reconcile','correction_approve']] as $id=>$rights) {
$user=$db::table('users')->where('id',$id)->first(['id','name','email']);
if(!$user || !$db::table('company_memberships')->where(['user_id'=>$id,'tenant_id'=>$company->tenant_id,'company_id'=>1])->exists()) throw new RuntimeException('Existing account/scope not confirmed.');
foreach($rights as $right) $rows[]=['user_id'=>$id,'name'=>$user->name,'email'=>$user->email,'tenant_id'=>$company->tenant_id,'company_id'=>1,'company'=>$company->name,'unit_id'=>1,'unit'=>$unit->name,'permission'=>'service_receivables.'.$right,'already_granted'=>$db::table('permission_grants')->where(['user_id'=>$id,'company_id'=>1,'unit_id'=>1,'permission'=>'service_receivables.'.$right])->exists()];
}
echo json_encode(['mode'=>'preview_only','grants'=>$rows,'applied'=>false],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR).PHP_EOL;
+16
View File
@@ -0,0 +1,16 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Services\ServiceReceivables::guard();
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
app(\App\Shared\Context\CompanyContext::class)->selectFor($f['reader_id'],$f['tenant'],$f['company'],$f['unit']);
$s=app(\App\Modules\Services\ServiceReceivables::class); $all=$s->listing($f['reader_id']);
$record=collect($all['records'])->first(fn($r)=>$r->kind==='obligation' && $r->invoice_id===$f['receivables']['invoice']);
$ob=collect($all['obligations'])->firstWhere('id',$record->id);
if($ob['balance']!=='0.00' || $ob['received']!=='1000.00' || $ob['allocated']!=='1000.00' || $ob['available']!=='0.00' || count($ob['receipts'])!==2) throw new RuntimeException('Browser scenario totals not confirmed.');
foreach($ob['receipts'] as $rc) if(!$rc['reconciled']) throw new RuntimeException('Synthetic reconciliation missing.');
$events=\Illuminate\Support\Facades\DB::table('sr_events')->pluck('id'); $delivered=0;
foreach($events as $event) if($s->deliver($event)) $delivered++;
$out=['database'=>'sgf_tempo_test','simulation_only'=>true,'policy'=>$s::POLICY,'notice'=>$s::NOTICE,'document_id'=>$record->invoice_id,'obligation_id'=>$ob['id'],'original'=>$ob['original'],'received'=>$ob['received'],'allocated'=>$ob['allocated'],'unallocated'=>$ob['available'],'balance'=>$ob['balance'],'receipts'=>array_values($ob['receipts']),'revision'=>$ob['revision'],'real_login_browser'=>true,'csrf_enabled'=>true,'actors_distinct'=>$f['manager_id']!==$f['reader_id'],'durable_events_delivered'=>$delivered,'no_fiscal_receipt_or_new_pdf'=>true];
file_put_contents(base_path('docs/proofs/receivables-browser-results.json'),json_encode($out,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo "Browser scenario verified: 1000.00 - 400.00 - 600.00 = 0.00; two simulated reconciliations; durable events delivered internally.\n";
+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();
\App\Modules\Services\ServiceReceivables::guard();
$in=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);
app(\App\Shared\Context\CompanyContext::class)->selectFor($in['actor'],$in['tenant'],$in['company'],$in['unit']);
if($in['pause']??false) {
$paused=false; \Illuminate\Support\Facades\DB::listen(function($q)use(&$paused){
if(!$paused && str_contains($q->sql,'"companies"') && str_contains($q->sql,'for update')) { $paused=true; echo "LOCK_HELD\n"; flush(); usleep(1200000); }
});
}
try {
$s=app(\App\Modules\Services\ServiceReceivables::class);
if($in['method']==='sql_allocate') {
$result=\Illuminate\Support\Facades\DB::transaction(function()use($in) {
if($in['pause']??false) { \Illuminate\Support\Facades\DB::table('companies')->where('id',$in['company'])->lockForUpdate()->firstOrFail(); }
$id=\Illuminate\Support\Facades\DB::table('sr_effects')->insertGetId($in['sql']); return ['id'=>$id];
});
} else $result=$s->{$in['method']}($in['id'],$in['actor'],$in['input'],$in['key']);
echo json_encode(['status'=>200,'result'=>$result],JSON_THROW_ON_ERROR).PHP_EOL;
} catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e) {
echo json_encode(['status'=>$e->getStatusCode()],JSON_THROW_ON_ERROR).PHP_EOL;
} catch(\Illuminate\Database\QueryException $e) {
if(!str_contains($e->getMessage(),'SGF_SR_')) throw $e;
echo json_encode(['status'=>409,'database_guard'=>true],JSON_THROW_ON_ERROR).PHP_EOL;
}
+45
View File
@@ -0,0 +1,45 @@
<?php
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();WorkshopWorkflow::guard();
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);$fx=$f['workshop'];$w=app(WorkshopWorkflow::class);
$base=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit']];$proof=[];
$select=fn($actor)=>app(CompanyContext::class)->selectFor($actor,$f['tenant'],$f['company'],$f['unit']);
$key=fn()=>(string)Str::uuid();$rev=fn($id)=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$id])->v;
$pair=function($one,$two)use($base){
$a=new Process([PHP_BINARY,'scripts/workshop-worker.php','--env=testing'],base_path(),null,json_encode($one+$base+['pause'=>true]));
$b=new Process([PHP_BINARY,'scripts/workshop-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('No overlapping lock.');$b->start();$a->wait();$b->wait();$out=[];
foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Synthetic 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;
};
$select($f['manager_id']);
$opening=['method'=>'open','actor'=>$f['manager_id'],'key'=>$key(),'input'=>['reference'=>'OT-REPLAY-'.Str::uuid(),'client_id'=>$fx['client'],'equipment_id'=>DB::table('wo_orders')->find($fx['order'])->equipment_id,'request'=>'Abertura concorrente.','location'=>'Local de teste','evidence'=>'Pedido sintético.']];
$out=$pair($opening,$opening);if($out[0]['status']!==200||$out[1]['status']!==200||!$out[1]['result']['replayed']||$out[0]['result']['id']!==$out[1]['result']['id'])throw new RuntimeException('Concurrent opening replay failed.');$proof['same_opening_one_order_one_replay']=true;
$o=$w->open($f['manager_id'],['reference'=>'OT-CONCURRENT-'.Str::uuid(),'client_id'=>$fx['client'],'equipment_id'=>DB::table('wo_orders')->find($fx['order'])->equipment_id,'request'=>'Prova de concorrência independente.','location'=>'Local de teste','evidence'=>'Somente ensaio isolado.'],$key())['id'];
$w->record($o,$f['manager_id'],['kind'=>'assignment','origin'=>'CONCURRENT-ASSIGN','version'=>1,'revision'=>0,'technician_id'=>$fx['technician_id']],$key());$select($fx['technician_id']);
$d=['kind'=>'task','origin'=>'CONCURRENT-TASK','version'=>1,'revision'=>$rev($o),'description'=>'Tarefa sintética concorrente.'];
$input=['method'=>'record','id'=>$o,'actor'=>$fx['technician_id'],'key'=>$key(),'input'=>$d];$out=$pair($input,$input);
if($out[0]['status']!==200||$out[1]['status']!==200||!$out[1]['result']['replayed']||$out[0]['result']['id']!==$out[1]['result']['id'])throw new RuntimeException('Record replay race failed.');
$task=$out[0]['result']['id'];$proof['same_record_one_insert_one_replay']=true;
$d=['kind'=>'time','task_id'=>$task,'origin'=>'CONCURRENT-TIME-A','version'=>1,'revision'=>$rev($o),'starts_at'=>$fx['day'].'T14:00:00+02:00','ends_at'=>$fx['day'].'T15:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato retrospectivo sintético.'];
$a=['method'=>'record','id'=>$o,'actor'=>$fx['technician_id'],'key'=>$key(),'input'=>$d];$b=$a;$b['key']=$key();$b['input']['origin']='CONCURRENT-TIME-B';$out=$pair($a,$b);
if(array_column($out,'status')!==[200,409])throw new RuntimeException('Overlap race failed.');$time=$out[0]['result']['id'];$proof['concurrent_same_technician_not_double_counted']=true;
$a=['method'=>'verify','id'=>$time,'actor'=>$fx['reviewer_id'],'key'=>$key(),'input'=>['version'=>1,'revision'=>$rev($o),'decision'=>'acknowledged','reason'=>'Somente auditoria; não autoriza execução.']];$out=$pair($a,$a);
if($out[0]['status']!==200||$out[1]['status']!==200||!$out[1]['result']['replayed'])throw new RuntimeException('Verification replay race failed.');$proof['verification_one_decision_one_replay']=true;
// A new version and a decision against its old version cannot both win.
$original=DB::table('wo_entries')->find($time);$a=['method'=>'record','id'=>$o,'actor'=>$fx['technician_id'],'key'=>$key(),'input'=>array_replace($d,['origin'=>$original->origin,'version'=>2,'previous_id'=>$time,'revision'=>$rev($o),'correction_reason'=>'Correcção concorrente explícita.','ends_at'=>$fx['day'].'T14:30:00+02:00'])];
$b=['method'=>'verify','id'=>$time,'actor'=>$fx['reviewer_id'],'key'=>$key(),'input'=>['version'=>1,'revision'=>$rev($o),'decision'=>'refused','reason'=>'Revisão antiga concorrente.']];$out=$pair($a,$b);
if(array_column($out,'status')!==[200,409])throw new RuntimeException('Version versus review race failed.');$proof['new_version_invalidates_old_review']=true;
$row=(array)DB::table('wo_entries')->find($out[0]['result']['id']);unset($row['id']);$row['origin']='SQL-OVERLAP-A';$row['version']=1;$row['previous_id']=null;$row['starts_at']=$fx['day'].'T16:00:00+02:00';$row['ends_at']=$fx['day'].'T17:00:00+02:00';$row['minutes']=60;$row['revision']=$rev($o);
$select($f['manager_id']);
$o2=$w->open($f['manager_id'],['reference'=>'OT-CROSS-'.Str::uuid(),'client_id'=>$fx['client'],'equipment_id'=>DB::table('wo_orders')->find($fx['order'])->equipment_id,'request'=>'Segunda ordem para disputa de tempo.','location'=>'Local de teste','evidence'=>'Somente ensaio de concorrência.'],$key())['id'];
$w->record($o2,$f['manager_id'],['kind'=>'assignment','origin'=>'CROSS-ASSIGN','version'=>1,'revision'=>0,'technician_id'=>$fx['technician_id']],$key());$select($fx['technician_id']);
$t2=$w->record($o2,$fx['technician_id'],['kind'=>'task','origin'=>'CROSS-TASK','version'=>1,'revision'=>1,'description'=>'Tarefa de outra ordem.'],$key())['id'];
$a=['method'=>'sql','actor'=>$fx['technician_id'],'sql'=>$row];$b=$a;$b['sql']['origin']='SQL-OVERLAP-B';$b['sql']['order_id']=$o2;$b['sql']['task_id']=$t2;$b['sql']['revision']=$rev($o2);$out=$pair($a,$b);
if(array_column($out,'status')!==[200,409]||!($out[1]['database_guard']??false))throw new RuntimeException('SQL concurrency guard failed.');$proof['raw_sql_revision_overlap_guard']=true;
file_put_contents(base_path('docs/proofs/workshop-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 scenarios passed only in sgf_tempo_test; no duplicate order, record, decision or effective time.\n";
+24
View File
@@ -0,0 +1,24 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\WorkshopWorkflow::guard();
$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
if(\Illuminate\Support\Facades\DB::table('wo_orders')->exists()) throw new RuntimeException('Workshop proof must be empty; nothing deleted.');
$day=now('Africa/Johannesburg')->subDay()->format('Y-m-d');
\Carbon\Carbon::setTestNow(\Carbon\Carbon::parse($day.' 08:00:00','Africa/Johannesburg'));
try {
$fx=\Tests\Support\WorkshopFixture::create($f+['manager'=>\App\Models\User::findOrFail($f['manager_id']),'reader'=>\App\Models\User::findOrFail($f['reader_id'])]);
$w=app(\App\Modules\Workshop\WorkshopWorkflow::class); $ctx=app(\App\Shared\Context\CompanyContext::class);
$select=fn($actor)=>$ctx->selectFor($actor,$f['tenant'],$f['company'],$f['unit']); $key=fn()=>(string)\Illuminate\Support\Str::uuid();
$select($f['manager_id']);
$order=$w->open($f['manager_id'],['reference'=>'OT-TEST-PRINCIPAL','client_id'=>$fx['client'],'equipment_identifier'=>'EQ-TEST-PC','equipment_description'=>'Computador sintético para prova','serial'=>'SER-TEST-PC','request'=>'Diagnóstico e reparação sintéticos.','location'=>'Local sintético','evidence'=>'Pedido artificial, sem operação real.'],$key())['id'];
$w->record($order,$f['manager_id'],['kind'=>'assignment','origin'=>'TEST-ASSIGN','version'=>1,'revision'=>0,'technician_id'=>$fx['technician']->id],$key());
$select($fx['technician']->id);
$task=$w->record($order,$fx['technician']->id,['kind'=>'task','origin'=>'TEST-TASK','version'=>1,'revision'=>1,'description'=>'Intervenção sintética principal.'],$key())['id'];
$select($f['manager_id']);
$auth=$w->record($order,$f['manager_id'],['kind'=>'authorization','origin'=>'TEST-AUTH','version'=>1,'revision'=>2,'task_id'=>$task,'authorized_by'=>'Representante sintético do cliente','capacity'=>'Responsável do cliente apenas no ensaio','authorized_at'=>$day.'T07:59:00+02:00','channel'=>'Documento artificial','scope'=>'Somente a tarefa principal de diagnóstico/reparação.','evidence'=>'TEST-COMPROVATIVO-1'],$key())['id'];
$out=['client'=>$fx['client'],'technician_id'=>$fx['technician']->id,'reviewer_id'=>$fx['reviewer']->id,'order'=>$order,'task'=>$task,'auth'=>$auth,'day'=>$day];
file_put_contents($file,json_encode($f+['workshop'=>$out],JSON_THROW_ON_ERROR));
} finally { \Carbon\Carbon::setTestNow(); }
echo "Workshop fixture prepared only in sgf_tempo_test: three synthetic roles and a historical test clock for authorization. No time/material records pre-created.\n";
+10
View File
@@ -0,0 +1,10 @@
<?php
// Read-only. No apply branch; technician and verifier real identities remain unassigned.
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('Development read-only scope refused.');
$db=\Illuminate\Support\Facades\DB::class;
$u=$db::table('users')->where('id',1)->first(['id','name','email']);$c=$db::table('companies')->where('id',1)->first(['id','tenant_id','name']);$unit=$db::table('units')->where(['id'=>1,'company_id'=>1])->first(['id','name']);
if(!$u||!$c||!$unit||!$db::table('company_memberships')->where(['user_id'=>1,'tenant_id'=>$c->tenant_id,'company_id'=>1])->exists())throw new RuntimeException('Existing opener/scope not confirmed.');
$rights=[];foreach(['view','open','assign','authorize'] as $r)$rights[]=['permission'=>'workshop.'.$r,'already_granted'=>$db::table('permission_grants')->where(['user_id'=>1,'tenant_id'=>$c->tenant_id,'company_id'=>1,'unit_id'=>1,'permission'=>'workshop.'.$r])->exists()];
echo json_encode(['mode'=>'preview_only','opener'=>$u,'company'=>$c,'unit'=>$unit,'proposed'=>$rights,'technician'=>'not_identified_no_grant','verifier'=>'not_identified_no_grant','applied'=>false],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR).PHP_EOL;
+18
View File
@@ -0,0 +1,18 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\WorkshopWorkflow::guard();
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);$fx=$f['workshop'];
app(\App\Shared\Context\CompanyContext::class)->selectFor($fx['reviewer_id'],$f['tenant'],$f['company'],$f['unit']);
$view=app(\App\Modules\Workshop\WorkshopWorkflow::class)->listing($fx['reviewer_id']);$order=collect($view['orders'])->firstWhere('id',$fx['order']);
if(!$order||$order['minutes_reported']!==105||$order['minutes_verified']!==105)throw new RuntimeException('Effective time not verified.');
$material=collect($order['materials'])->firstWhere('resource','CABO-TEST');
if(!$material||$material['delivered']!=='3'||$material['consumed']!=='2'||$material['returned']!=='1'||$material['unexplained']!=='0'||$material['verified_consumed']!=='2')throw new RuntimeException('Material observations not verified.');
$exception=collect($order['entries'])->firstWhere('origin','TEST-EXCEPTION-PAST');
if(!$exception||!$exception->outside_authority||$exception->decision?->decision!=='acknowledged')throw new RuntimeException('Historical exception lost or incorrectly authorized.');
$refused=collect($order['entries'])->firstWhere('origin','TEST-REVIEW-REFUSAL');
if(!$refused||$refused->decision?->decision!=='refused')throw new RuntimeException('Explicit refusal not demonstrated.');
$later=collect($order['entries'])->firstWhere('origin','TEST-AUTH-EXTRA');if(!$later)throw new RuntimeException('Later scope authorization missing.');
$out=['database'=>'sgf_tempo_test','policy'=>\App\Modules\Workshop\WorkshopWorkflow::POLICY,'synthetic_only'=>true,'real_browser_login'=>true,'csrf_enabled'=>true,'three_distinct_actors'=>count(array_unique([$f['manager_id'],$fx['technician_id'],$fx['reviewer_id']]))===3,'order_id'=>$order['id'],'minutes_reported'=>105,'minutes_verified'=>105,'material'=>$material,'unauthorized_start_refused_in_browser'=>true,'past_exception_preserved_after_later_authorization'=>true,'exception_decision'=>'acknowledged_not_authorized','refused_entry'=>$refused->id,'refusal_reason'=>$refused->decision->reason,'real_operations_enabled'=>false];
file_put_contents(base_path('docs/proofs/workshop-browser-results.json'),json_encode($out,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
echo "Browser workshop proof verified: 105 minutes, 3/2/1 material observations, explicit refusal and historical exception still unauthorized.\n";
+18
View File
@@ -0,0 +1,18 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\WorkshopWorkflow::guard();
$in=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);
app(\App\Shared\Context\CompanyContext::class)->selectFor($in['actor'],$in['tenant'],$in['company'],$in['unit']);
if($in['pause']??false) {
$paused=false;\Illuminate\Support\Facades\DB::listen(function($q)use(&$paused){if(!$paused&&str_contains($q->sql,'"companies"')&&str_contains($q->sql,'for update')){$paused=true;echo "LOCK_HELD\n";flush();usleep(1200000);}});
}
try {
$w=app(\App\Modules\Workshop\WorkshopWorkflow::class);
if($in['method']==='sql') {
$out=\Illuminate\Support\Facades\DB::transaction(function()use($in){if($in['pause']??false)\Illuminate\Support\Facades\DB::table('companies')->where('id',$in['company'])->lockForUpdate()->firstOrFail();return ['id'=>\Illuminate\Support\Facades\DB::table('wo_entries')->insertGetId($in['sql'])];});
} elseif($in['method']==='open') $out=$w->open($in['actor'],$in['input'],$in['key']);
else $out=$w->{$in['method']}($in['id'],$in['actor'],$in['input'],$in['key']);
echo json_encode(['status'=>200,'result'=>$out],JSON_THROW_ON_ERROR).PHP_EOL;
} catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){echo json_encode(['status'=>$e->getStatusCode()],JSON_THROW_ON_ERROR).PHP_EOL;}
catch(\Illuminate\Database\QueryException $e){if(!str_contains($e->getMessage(),'SGF_WO_'))throw $e;echo json_encode(['status'=>409,'database_guard'=>true],JSON_THROW_ON_ERROR).PHP_EOL;}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace Tests\Feature;
use App\Modules\Services\ServiceReceivables;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\ReceivablesFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class ServiceReceivablesTest extends TestCase
{
use RefreshDatabase;
private array $f; private string $token; private int $ob;
protected function setUp(): void { parent::setUp(); $this->f=ReceivablesFixture::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 commandPost(string $path,array $data,?string $key=null) {
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/service-receivables'.$path,$data);
}
private function rev(): int { return isset($this->ob)?DB::table('sr_effects')->where('obligation_id',$this->ob)->count():0; }
private function proposal(string $kind,array $extra=[],?string $key=null) {
return $this->commandPost('/proposals',array_replace(['kind'=>$kind,'invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-MANUAL','external_id'=>(string)Str::uuid(),'revision'=>$this->rev()],isset($this->ob)?['obligation_id'=>$this->ob]:[],$extra),$key);
}
private function decide(int $id,?string $key=null,array $extra=[]) { return $this->commandPost('/'.$id.'/decision',array_replace(['version'=>1,'revision'=>$this->rev(),'decision'=>'approved','reason'=>'Confirmação sintética explícita.'],$extra),$key); }
private function open(?string $due=null): void {
$this->ob=$this->proposal('obligation',['due_date'=>$due])->assertOk()->json('id');
$this->login('reader'); $this->decide($this->ob)->assertOk(); $this->login();
}
private function receipt(string $amount): int {
$id=$this->proposal('receipt',['amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Origem artificial sem dinheiro.'])->assertOk()->json('id');
$this->login('reader'); $this->decide($id)->assertOk(); $this->login(); return $id;
}
private function allocate(int $id,string $amount,?string $key=null,?int $rev=null) { return $this->commandPost('/'.$id.'/allocate',['amount'=>$amount,'version'=>1,'revision'=>$rev??$this->rev()],$key); }
private function ledgerView() { return $this->getJson('/api/service-receivables')->assertOk(); }
private function correction(string $action,array $data=[]): int { return $this->proposal('correction',['action'=>$action,'reason'=>'Correcção sintética justificada.']+$data)->assertOk()->json('id'); }
private function observation(int $receipt,string $amount): int {
return $this->proposal('observation',['receipt_id'=>$receipt,'amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Observação artificial independente.'])->assertOk()->json('id');
}
public function test_partial_receipts_exact_allocation_idempotency_and_independent_reconciliation(): void {
$before=DB::table('si_invoices')->get()->toJson(); $this->open(); $a=$this->receipt('400.00');
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00')->assertJsonPath('obligations.0.due_state','unknown');
$revision=$this->rev(); $key=(string)Str::uuid(); $id=$this->allocate($a,'400.00',$key,$revision)->assertOk()->json('id');
$this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->allocate($a,'399.99',$key,$revision)->assertStatus(409);
$b=$this->receipt('600'); $this->allocate($b,'600')->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.received','1000.00')->assertJsonPath('obligations.0.allocated','1000.00');
$oa=$this->observation($a,'400'); $ob=$this->observation($b,'600');
$this->login('reader'); $this->decide($oa)->assertOk(); $this->decide($ob)->assertOk();
$v=$this->ledgerView(); $receipts=$v->json('obligations.0.receipts'); $this->assertTrue($receipts[$a]['reconciled']); $this->assertTrue($receipts[$b]['reconciled']);
$this->assertSame($before,DB::table('si_invoices')->get()->toJson());
$this->assertSame(DB::table('sr_commands')->count(),DB::table('sr_events')->count());
$this->assertSame(DB::table('sr_events')->count(),DB::table('sr_outbox')->count());
$svc=app(ServiceReceivables::class); $event=DB::table('sr_events')->value('id'); $this->assertTrue($svc->deliver($event)); $this->assertFalse($svc->deliver($event));
}
public function test_excess_divergence_no_tolerance_and_duplicate_source(): void {
$this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('700');
$this->allocate($b,'700')->assertStatus(409); $this->ledgerView()->assertJsonPath('obligations.0.balance','600.00');
$this->allocate($b,'600')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.available','100.00')->assertJsonPath('obligations.0.balance','0.00');
$o=$this->observation($a,'399.99'); $this->login('reader'); $this->decide($o)->assertOk()->assertJsonPath('event','divergence.simulation');
$this->assertSame('0.01',DB::table('sr_effects')->where('kind','divergence')->value('amount'));
$this->login(); $r=DB::table('sr_records')->find($b);
$this->proposal('receipt',['source'=>$r->source,'external_id'=>$r->external_id,'amount'=>'700','date'=>now()->format('Y-m-d'),'evidence'=>'Repeated source'])->assertStatus(409);
}
public function test_reduction_reversal_dependencies_and_preserved_original(): void {
$this->open(); $a=$this->receipt('400'); $af=$this->allocate($a,'400')->assertOk()->json('id');
$c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-100','line'=>'1']);
$this->login('reader'); $this->decide($c)->assertOk(); $this->login();
$b=$this->receipt('500'); $bf=$this->allocate($b,'500')->assertOk()->json('id');
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.original','1000.00')->assertJsonPath('obligations.0.reduced','100.00');
$fail=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-200','line'=>'1']);
$this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login();
$fail=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login();
$undo=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $this->decide($undo)->assertOk(); $this->login();
$this->ledgerView()->assertJsonPath('obligations.0.balance','500.00');
$undo=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($undo)->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.received','400.00')->assertJsonPath('obligations.0.available','0.00');
$this->assertDatabaseHas('sr_effects',['id'=>$bf,'kind'=>'allocate','amount'=>'500.00']);
}
public function test_permissions_revocation_self_approval_stale_versions_and_cross_scope(): void {
$this->open(); $a=$this->receipt('400');
$this->login('reader'); $this->allocate($a,'400')->assertForbidden(); $this->proposal('receipt',[])->assertForbidden();
$this->login(); IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_receivables.confirm',$this->f['unit']);
$r=$this->proposal('receipt',['amount'=>'100','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertOk()->json('id');
$this->decide($r)->assertForbidden(); $this->allocate($a,'400',null,0)->assertStatus(409);
$this->commandPost('/'.$a.'/allocate',['amount'=>'400','revision'=>$this->rev(),'version'=>2])->assertUnprocessable();
$c=$this->correction('due',['due_date'=>'2026-11-10']); $this->allocate($a,'1')->assertOk(); $this->login('reader'); $this->decide($c)->assertStatus(409); $this->login();
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_receivables.allocate')->delete();
$this->allocate($a,'1')->assertForbidden();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
$this->commandPost('/'.$a.'/allocate',['amount'=>'1','version'=>1,'revision'=>0])->assertStatus(409);
}
public function test_invalid_money_environment_and_sql_immutability(): void {
$this->open();
foreach(['0','-1','1.001','1e3','10000000000000000'] as $amount) $this->proposal('receipt',['amount'=>$amount,'date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertUnprocessable();
$this->proposal('receipt',['amount'=>'1','currency'=>'USD'])->assertUnprocessable();
foreach(['sr_records','sr_effects','sr_events','sr_outbox','sr_commands'] as $table) {
try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('Immutable deletion'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
$this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->proposal('obligation')->assertForbidden();
$this->ledgerView()->assertJsonPath('blocked',true)->assertJsonPath('obligations',[]);
try { ServiceReceivables::guard(); $this->fail('CLI bypass'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
public function test_outbox_failure_rolls_back_and_lost_response_replay_recovers(): void {
$key=(string)Str::uuid(); $input=['kind'=>'obligation','invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-FAIL','external_id'=>'failure-1','revision'=>0];
DB::unprepared("CREATE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()");
$this->commandPost('/proposals',$input,$key)->assertStatus(500); $this->assertDatabaseCount('sr_records',0); $this->assertDatabaseCount('sr_commands',0); $this->assertDatabaseCount('sr_events',0);
DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox');
$id=$this->commandPost('/proposals',$input,$key)->assertOk()->json('id');
$this->commandPost('/proposals',$input,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true); $this->assertDatabaseCount('sr_records',1);
}
private function sqlDenied(callable $write): void {
try { DB::transaction($write); $this->fail('SQL invariant bypass'); }
catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
public function test_sql_protects_linkage_scale_aggregate_and_history_without_eloquent(): void {
$this->open(); $a=$this->receipt('700'); $this->allocate($a,'700')->assertOk(); $b=$this->receipt('400');
$row=(array)DB::table('sr_effects')->where('kind','allocate')->first(); unset($row['id']);
$row['record_id']=$b; $row['receipt_id']=$b; $row['amount']='400.00'; $row['revision']=$this->rev();
$this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row));
$row['amount']='0.001';
try { DB::transaction(fn()=>DB::table('sr_effects')->insert($row)); $this->fail('Scale silently rounded'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('check constraint',$e->getMessage()); }
$row['amount']='1.00'; $row['obligation_id']=999999; $this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row));
$r=(array)DB::table('sr_records')->find($a); unset($r['id']); $r['external_id']='sql-transfer'; $r['unit_id']=$this->f['hidden'];
$this->sqlDenied(fn()=>DB::table('sr_records')->insert($r));
$this->sqlDenied(fn()=>DB::table('sr_records')->where('id',$a)->update(['client_id'=>$this->f['client']+1]));
$this->ledgerView()->assertJsonPath('obligations.0.balance','300.00')->assertJsonPath('obligations.0.allocated','700.00');
}
public function test_reconciliation_undo_reuse_and_post_settlement_reduction(): void {
$this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('600'); $bf=$this->allocate($b,'600')->assertOk()->json('id');
$ob=$this->observation($b,'600'); $this->login('reader'); $cf=$this->decide($ob)->assertOk()->json('id');
$this->decide($ob)->assertStatus(409); $this->login();
$other=$this->observation($b,'600'); $this->login('reader'); $this->decide($other)->assertStatus(409); $this->login();
$reverse=$this->correction('reverse_reconcile',['target_effect_id'=>$cf]); $this->login('reader'); $this->decide($reverse)->assertOk(); $this->login();
$reverse=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $key=(string)Str::uuid(); $rev=$this->rev(); $this->decide($reverse,$key)->assertOk();
$this->decide($reverse,$key,['revision'=>$rev])->assertOk()->assertJsonPath('replayed',true); $this->login();
$c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-AFTER','line'=>'1']); $this->login('reader'); $this->decide($c)->assertOk(); $this->login();
$this->allocate($b,'500')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.available','100.00');
$this->login('reader'); $this->decide($other)->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00');
}
public function test_due_boundaries_missing_dates_and_consumer_failure_are_explicit(): void {
$this->open('2026-11-10');
$this->travelTo(\Carbon\Carbon::parse('2026-11-10 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','not_due');
$this->travelTo(\Carbon\Carbon::parse('2026-11-11 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','overdue'); $this->travelBack();
$a=$this->receipt('1000'); $this->allocate($a,'1000')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.due_state','settled');
$event=DB::table('sr_events')->value('id');
DB::unprepared("CREATE OR REPLACE FUNCTION sr_delivery_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_DELIVERY_FAILURE'; END; $$; CREATE TRIGGER sr_delivery_failure BEFORE INSERT ON sr_deliveries FOR EACH ROW EXECUTE FUNCTION sr_delivery_fail()");
try { app(ServiceReceivables::class)->deliver($event); $this->fail('Failure not injected'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('TEST_DELIVERY_FAILURE',$e->getMessage()); }
$this->assertDatabaseCount('sr_deliveries',0); DB::unprepared('DROP TRIGGER sr_delivery_failure ON sr_deliveries');
$this->assertTrue(app(ServiceReceivables::class)->deliver($event)); $this->assertFalse(app(ServiceReceivables::class)->deliver($event));
}
public function test_foreign_identifiers_missing_evidence_and_db_target_guard(): void {
$this->open();
$this->proposal('receipt',['amount'=>'1'])->assertUnprocessable();
$this->proposal('receipt',['amount'=>'1','date'=>'2099-01-01','evidence'=>'Synthetic'])->assertUnprocessable();
$this->proposal('observation',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','receipt_id'=>999999])->assertForbidden();
$this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','invoice_id'=>999999])->assertForbidden();
$this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','client_id'=>999999])->assertUnprocessable();
$a=$this->receipt('100'); $target=$this->allocate($a,'100')->assertOk()->json('id');
$reverse=$this->correction('reverse_allocation',['target_effect_id'=>$target]);
$this->login('reader'); $this->decide($reverse)->assertOk(); $this->login();
$again=$this->correction('reverse_allocation',['target_effect_id'=>$target]); $this->login('reader'); $this->decide($again)->assertStatus(409);
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','100.00');
}
public function test_financial_effect_rollback_live_scope_and_wrong_database_guard(): void {
$this->open(); $a=$this->receipt('400'); $revision=$this->rev(); $before=DB::table('sr_effects')->count(); $key=(string)Str::uuid();
DB::unprepared("CREATE OR REPLACE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()");
$this->allocate($a,'400',$key,$revision)->assertStatus(500); $this->assertDatabaseCount('sr_effects',$before);
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00');
DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox');
$this->allocate($a,'400',$key,$revision)->assertOk(); $this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('replayed',true);
foreach(['view','allocate'] as $right) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],'service_receivables.'.$right,$this->f['siblingUnit']);
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk()->json('context.token');
$this->allocate($a,'1')->assertForbidden(); $this->ledgerView()->assertJsonPath('records',[]);
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertForbidden();
config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);
try { ServiceReceivables::guard(); $this->fail('Wrong test destination allowed'); } catch(\RuntimeException $e) { $this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage()); }
}
public function test_proposed_refused_obligation_is_not_open_and_original_identity_is_frozen(): void {
$this->ob=$this->proposal('obligation')->assertOk()->json('id');
$this->ledgerView()->assertJsonPath('obligations.0.opened',false)->assertJsonPath('obligations.0.due_state','not_open');
$data=json_decode(DB::table('sr_records')->find($this->ob)->data,true);
$this->assertSame(hash('sha256',DB::table('si_invoices')->find($this->f['invoice'])->snapshot),$data['origin']['snapshot_sha256']);
$this->login('reader'); $this->decide($this->ob,null,['decision'=>'refused'])->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.state','refused')->assertJsonPath('obligations.0.opened',false);
$this->login(); $this->proposal('receipt',['amount'=>'400','date'=>now()->format('Y-m-d'),'evidence'=>'No open obligation'])->assertStatus(409);
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\WorkshopFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class WorkshopTest extends TestCase
{
use RefreshDatabase;
private array $f; private string $token; private int $order; private int $task; private int $auth;
protected function setUp(): void { parent::setUp(); $this->f=WorkshopFixture::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 $path,array $input,?string $key=null) {
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/workshop'.$path,$input);
}
private function rev(): int { return isset($this->order)?(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$this->order])->v:0; }
private function openOrder(?string $reference=null): void {
$this->order=$this->send('/orders',['reference'=>$reference??'OT-TEST-'.Str::uuid(),'client_id'=>$this->f['client'],'equipment_identifier'=>'EQ-'.Str::uuid(),'equipment_description'=>'PC sintético','serial'=>'SER-TEST','request'=>'Diagnóstico sintético','location'=>'Local sintético','evidence'=>'Pedido artificial.'])->assertOk()->json('id');
$this->record('assignment',['technician_id'=>$this->f['technician']->id])->assertOk();
$this->login('technician'); $this->task=$this->record('task',['description'=>'Reparação sintética autorizável.'])->assertOk()->json('id');
}
private function record(string $kind,array $data=[],?string $key=null) {
return $this->send('/orders/'.$this->order.'/entries',array_replace(['kind'=>$kind,'origin'=>$kind.'-'.Str::uuid(),'version'=>1,'revision'=>$this->rev()],$data),$key);
}
private function authorize(): void {
$this->login(); $this->auth=$this->record('authorization',['task_id'=>$this->task,'authorized_by'=>'Responsável do cliente sintético','capacity'=>'Representante declarado de teste','authorized_at'=>now()->subMinute()->toIso8601String(),'channel'=>'Documento de teste','scope'=>'Apenas a tarefa sintética identificada.','evidence'=>'TEST-AUTH-1'])->assertOk()->json('id'); $this->login('technician');
}
private function verifyEntry(int $id,string $decision='verified',?string $key=null,array $changes=[]) {
$e=DB::table('wo_entries')->find($id);
return $this->send('/entries/'.$id.'/verification',array_replace(['version'=>$e->version,'revision'=>$this->rev(),'decision'=>$decision,'reason'=>'Revisão sintética explícita.'],$changes),$key);
}
private function ledger() { return $this->getJson('/api/workshop')->assertOk(); }
public function test_authorized_work_time_materials_and_distinct_verification(): void {
$this->travelTo(\Carbon\Carbon::parse('2026-10-27 08:00:00',WorkshopWorkflow::ZONE)); $this->openOrder();
$this->record('start',['task_id'=>$this->task,'evidence'=>'Tentativa sem autorização.'])->assertStatus(409);
$this->authorize(); $this->travelTo(\Carbon\Carbon::parse('2026-10-27 09:00:00',WorkshopWorkflow::ZONE));
$start=$this->record('start',['task_id'=>$this->task,'authorization_id'=>$this->auth,'evidence'=>'Início explícito.'])->assertOk()->assertJsonPath('outside_authority',false)->json('id');
$this->travelTo(\Carbon\Carbon::parse('2026-10-27 11:00:00',WorkshopWorkflow::ZONE));
$time=$this->record('time',['task_id'=>$this->task,'authorization_id'=>$this->auth,'start_id'=>$start,'starts_at'=>'2026-10-27T09:00:00+02:00','ends_at'=>'2026-10-27T11:00:00+02:00','mode'=>'started','time_type'=>'work','pauses'=>[['start'=>'2026-10-27T10:00:00+02:00','end'=>'2026-10-27T10:15:00+02:00']],'evidence'=>'Trabalho sintético.'])->assertOk()->assertJsonPath('outside_authority',false)->json('id');
$ids=[$this->task,$time];
foreach(['delivered'=>'3','consumed'=>'2','returned'=>'1'] as $event=>$qty) $ids[]=$this->record('material',['task_id'=>$this->task,'authorization_id'=>$this->auth,'starts_at'=>now()->toIso8601String(),'resource'=>'CABO-TEST','unit'=>'UN','owner'=>'company','event'=>$event,'quantity'=>$qty,'evidence'=>'Material apenas observado.'])->assertOk()->json('id');
$this->ledger()->assertJsonPath('orders.0.minutes_reported',105)->assertJsonPath('orders.0.minutes_verified',0)->assertJsonPath('orders.0.materials.0.unexplained','0');
$this->verifyEntry($time)->assertForbidden();
$this->login('reviewer'); foreach($ids as $id) $this->verifyEntry($id)->assertOk();
$this->ledger()->assertJsonPath('orders.0.minutes_verified',105)->assertJsonPath('orders.0.state','records_verified')->assertJsonPath('orders.0.materials.0.verified_consumed','2');
$this->assertSame(DB::table('wo_commands')->count(),DB::table('wo_events')->count()); $this->assertSame(DB::table('wo_events')->count(),DB::table('wo_outbox')->count()); $this->travelBack();
}
public function test_unauthorized_past_occurrence_is_auditable_never_retroactively_authorized(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>now()->subHours(2)->toIso8601String(),'ends_at'=>now()->subHour()->toIso8601String(),'mode'=>'reported','time_type'=>'work','evidence'=>'Ocorrência já acontecida sem cobertura.'];
$id=$this->record('time',$d)->assertOk()->assertJsonPath('outside_authority',true)->json('id');
$this->authorize(); $this->assertTrue(DB::table('wo_entries')->find($id)->outside_authority);
$this->login('reviewer'); $this->verifyEntry($id)->assertStatus(409); $this->verifyEntry($id,'acknowledged')->assertOk();
$this->ledger()->assertJsonPath('orders.0.state','exception_review')->assertJsonPath('orders.0.minutes_verified',0);
$this->login('technician'); $r=DB::table('wo_entries')->find($id);
$new=$this->record('time',$d+['previous_id'=>$id,'version'=>2,'origin'=>$r->origin,'correction_reason'=>'Precisão do relato, sem apagar excepção.'])->assertOk()->assertJsonPath('outside_authority',true)->json('id');
$this->login('reviewer'); $this->verifyEntry($id)->assertStatus(409); $this->verifyEntry($new)->assertStatus(409);
}
public function test_scope_change_requires_new_authorization_and_exact_version(): void {
$this->openOrder(); $this->authorize(); $task=DB::table('wo_entries')->find($this->task);
$new=$this->record('task',['description'=>'Trabalho adicional fora do primeiro âmbito.','previous_id'=>$task->id,'version'=>2,'origin'=>$task->origin,'correction_reason'=>'Cliente pediu alteração.'])->assertOk()->json('id');
$this->record('start',['task_id'=>$new,'authorization_id'=>$this->auth,'evidence'=>'Autorização antiga não serve.'])->assertStatus(409);
$this->record('start',['task_id'=>$task->id,'authorization_id'=>$this->auth,'evidence'=>'Tarefa antiga.'])->assertStatus(409);
$this->task=$new; $this->authorize(); $this->record('start',['task_id'=>$new,'authorization_id'=>$this->auth,'evidence'=>'Novo âmbito autorizado.'])->assertOk();
$this->login('reviewer'); $this->verifyEntry($task->id)->assertStatus(409); $this->verifyEntry($new,'refused')->assertOk();
}
public function test_overlap_invalid_pauses_units_and_permissions(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Apontamento retrospectivo.'];
$this->record('time',$d)->assertOk(); $this->record('time',$d)->assertStatus(409);
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T11:00:00+02:00','ends_at'=>'2026-10-26T12:00:00+02:00']))->assertOk();
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T13:00:00+02:00','ends_at'=>'2026-10-26T14:00:00+02:00','pauses'=>[['start'=>'2026-10-26T13:10:00+02:00','end'=>'2026-10-26T14:10:00+02:00']]]))->assertUnprocessable();
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T14:00:00+02:00','ends_at'=>'2026-10-26T14:00:30+02:00']))->assertUnprocessable();
$m=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','resource'=>'CABO','unit'=>'UN','owner'=>'company','event'=>'required','quantity'=>'3','evidence'=>'Necessidade, não consumo.'];
$this->record('material',$m)->assertOk(); $this->record('material',array_replace($m,['unit'=>'KG']))->assertStatus(409); $this->record('material',array_replace($m,['event'=>'reserved']))->assertUnprocessable();
$this->login('reader'); $this->getJson('/api/workshop')->assertForbidden();
$this->login('manager'); $this->record('task',['description'=>'Abertura não é execução.'])->assertForbidden();
}
public function test_idempotency_failures_revocation_and_real_guard(): void {
$this->openOrder(); $d=['kind'=>'task','description'=>'Tarefa idempotente','origin'=>'TEST-REPEAT','version'=>1,'revision'=>$this->rev()]; $key=(string)Str::uuid();
DB::unprepared("CREATE OR REPLACE FUNCTION wo_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER wo_failure BEFORE INSERT ON wo_outbox FOR EACH ROW EXECUTE FUNCTION wo_fail()");
$before=DB::table('wo_entries')->count(); $this->send('/orders/'.$this->order.'/entries',$d,$key)->assertStatus(500); $this->assertDatabaseCount('wo_entries',$before);
DB::unprepared('DROP TRIGGER wo_failure ON wo_outbox');
$id=$this->send('/orders/'.$this->order.'/entries',$d,$key)->assertOk()->json('id'); $this->send('/orders/'.$this->order.'/entries',$d,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->send('/orders/'.$this->order.'/entries',array_replace($d,['description'=>'Different']),$key)->assertStatus(409);
$this->record('task',['description'=>'Duplicate source','origin'=>'TEST-REPEAT'])->assertStatus(409);
DB::table('permission_grants')->where(['user_id'=>$this->f['technician']->id,'permission'=>'workshop.record'])->delete(); $this->record('task',['description'=>'Revogado'])->assertForbidden();
$this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->record('task',[])->assertForbidden();
try { WorkshopWorkflow::guard(); $this->fail('CLI bypass'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
private function sqlDenied(callable $write): void {
try { DB::transaction($write); $this->fail('Database invariant bypass'); }
catch(\Illuminate\Database\QueryException $e) { $this->assertMatchesRegularExpression('/SGF_(WO_|APPEND_ONLY_AUDIT)/',$e->getMessage()); }
}
public function test_sql_immutability_versions_overlap_and_distinct_verifier(): void {
$this->openOrder(); $d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato de auditoria.'];
$id=$this->record('time',$d)->assertOk()->json('id');
$this->sqlDenied(fn()=>DB::table('wo_entries')->where('id',$id)->update(['outside_authority'=>false]));
foreach(['wo_entries','wo_orders','wo_equipment','wo_commands','wo_events','wo_outbox'] as $table) $this->sqlDenied(fn()=>DB::table($table)->delete());
$row=(array)DB::table('wo_entries')->find($id); unset($row['id']); $row['origin']='SQL-OVERLAP';$row['revision']=$this->rev();
$this->sqlDenied(fn()=>DB::table('wo_entries')->insert($row));
$row=(array)DB::table('wo_orders')->find($this->order);unset($row['id']);$row['unit_id']=$this->f['hidden'];$row['reference']='SQL-CROSS';
// Same equipment in another unit is not a transfer of an existing order: creation must remain an authorized server command.
$this->sqlDenied(fn()=>DB::table('wo_orders')->where('id',$this->order)->update(['unit_id'=>$this->f['hidden']]));
IdentityFixture::grant($this->f['technician']->id,$this->f['tenant'],$this->f['company'],'workshop.verify',$this->f['unit']);
$this->verifyEntry($id,'acknowledged')->assertForbidden();
$this->sqlDenied(fn()=>DB::table('wo_decisions')->insert(['entry_id'=>$id,'entry_version'=>1,'revision'=>$this->rev(),'decision'=>'acknowledged','reason'=>'SQL self decision.','actor_id'=>$this->f['technician']->id,'created_at'=>now()]));
}
public function test_correction_replaces_current_view_not_verified_history(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato sem autorização.','origin'=>'CORRECTION-ORIGIN'];
$id=$this->record('time',$d)->assertOk()->json('id'); $this->login('reviewer'); $this->verifyEntry($id,'refused')->assertOk();$this->login('technician');
$next=$this->record('time',array_replace($d,['previous_id'=>$id,'version'=>2,'ends_at'=>'2026-10-26T10:00:00+02:00','correction_reason'=>'Corrigir hora final conforme evidência.']))->assertOk()->json('id');
$this->ledger()->assertJsonPath('orders.0.minutes_reported',60)->assertJsonPath('orders.0.minutes_verified',0);
$this->assertDatabaseHas('wo_entries',['id'=>$id,'minutes'=>120]);$this->assertDatabaseHas('wo_decisions',['entry_id'=>$id,'decision'=>'refused']);
$this->login('reviewer');$this->verifyEntry($id)->assertStatus(409);$this->verifyEntry($next,'acknowledged')->assertOk();
}
public function test_isolation_assignment_change_context_and_wrong_database(): void {
$this->openOrder(); $this->login();
foreach(['view','open','assign','authorize'] as $r) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],'workshop.'.$r,$this->f['siblingUnit']);
$old=$this->token;
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk()->json('context.token');
$this->record('assignment',['technician_id'=>$this->f['technician']->id])->assertForbidden();$this->ledger()->assertJsonPath('orders',[]);
$this->withHeader('X-SGF-Context',$old)->postJson('/api/workshop/orders/'.$this->order.'/entries',[])->assertStatus(409);
$this->login('technician'); DB::table('permission_grants')->where(['user_id'=>$this->f['technician']->id,'permission'=>'unit.view','unit_id'=>$this->f['unit']])->delete();
$this->getJson('/api/workshop')->assertForbidden();
config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);
try { WorkshopWorkflow::guard();$this->fail('Wrong database'); } catch(\RuntimeException $e){$this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage());}
}
public function test_two_technicians_can_work_simultaneously_but_not_one_technician_twice(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato de dois técnicos distintos.'];
$this->record('time',$d)->assertOk();
foreach(['view','record'] as $right) IdentityFixture::grant($this->f['reviewer']->id,$this->f['tenant'],$this->f['company'],'workshop.'.$right,$this->f['unit']);
$this->login();$this->record('assignment',['technician_id'=>$this->f['reviewer']->id])->assertOk();
$this->login('technician');$this->record('time',$d)->assertForbidden();
$this->login('reviewer');$this->record('time',$d)->assertOk();$this->ledger()->assertJsonPath('orders.0.minutes_reported',240);
$this->record('time',$d)->assertStatus(409);
}
public function test_open_replay_and_material_correspondence_are_not_stock(): void {
$this->openOrder();
foreach(['required'=>'4','delivered'=>'3','consumed'=>'2','returned'=>'1'] as $event=>$q) $this->record('material',['task_id'=>$this->task,'resource'=>'TEST-MAT','event'=>$event,'quantity'=>$q,'owner'=>'client','unit'=>'UN','starts_at'=>now()->subMinute()->toIso8601String(),'evidence'=>'Material do cliente, observado apenas.'])->assertOk();
$this->ledger()->assertJsonPath('orders.0.materials.0.required','4')->assertJsonPath('orders.0.materials.0.consumed','2')->assertJsonPath('orders.0.materials.0.unexplained','0');
$this->assertDatabaseCount('sr_records',0);$this->assertDatabaseCount('si_invoices',0);
$this->record('material',['task_id'=>$this->task,'resource'=>'TEST-MAT','event'=>'consumed','quantity'=>'0','owner'=>'client','unit'=>'UN','starts_at'=>now()->toIso8601String(),'evidence'=>'Zero não é consumo.'])->assertUnprocessable();
}
public function test_order_creation_failure_is_atomic_and_replay_does_not_duplicate_equipment(): void {
$d=['reference'=>'OT-ATOMIC-OPEN','client_id'=>$this->f['client'],'equipment_identifier'=>'EQ-ATOMIC','equipment_description'=>'Equipamento sintético','request'=>'Pedido de teste','location'=>'Local de teste','evidence'=>'Evidência artificial.'];$key=(string)Str::uuid();
DB::unprepared("CREATE OR REPLACE FUNCTION wo_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER wo_failure BEFORE INSERT ON wo_outbox FOR EACH ROW EXECUTE FUNCTION wo_fail()");
$this->send('/orders',$d,$key)->assertStatus(500);$this->assertDatabaseCount('wo_orders',0);$this->assertDatabaseCount('wo_equipment',0);$this->assertDatabaseCount('wo_commands',0);
DB::unprepared('DROP TRIGGER wo_failure ON wo_outbox');$id=$this->send('/orders',$d,$key)->assertOk()->json('id');
$this->send('/orders',$d,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->send('/orders',$d)->assertStatus(409);$this->assertDatabaseCount('wo_orders',1);$this->assertDatabaseCount('wo_equipment',1);
}
}
+4 -4
View File
@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
final class InvoiceFixture
{
public static function create(?array $f=null): array
public static function create(?array $f=null,string $unitPrice='7850.00'): array
{
TestingDatabaseGuard::assertSafe(); $f ??= DocumentPreviewFixture::create();
$actor=$f['manager']->id; $validator=$f['reader']->id;
@@ -27,7 +27,7 @@ final class InvoiceFixture
$client=$w->save('clients',null,$actor,['name'=>'Cliente sintético Horizonte'],(string)Str::uuid())['id'];
$service=$w->save('services',null,$actor,['name'=>'Alojamento mensal sintético'],(string)Str::uuid())['id'];
$today=now(ServiceInvoicing::ZONE)->format('Y-m-d'); $start=now(ServiceInvoicing::ZONE)->subDays(30)->format('Y-m-d'); $end=now(ServiceInvoicing::ZONE)->subDay()->format('Y-m-d');
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação SINTÉTICA para emissão de teste','client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'7850.00','description'=>'Serviço fictício para demonstração isolada.']]],(string)Str::uuid())['id'];
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação SINTÉTICA para emissão de teste','client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>$unitPrice,'description'=>'Serviço fictício para demonstração isolada.']]],(string)Str::uuid())['id'];
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Factura de TESTE — alojamento'],(string)Str::uuid())['id'];
$prepare->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$start,'period_end'=>$end,'units'=>['período'],'payment_terms'=>'Condição sintética: vencimento na data de emissão.','proposed_due_date'=>$today,'payment_instruction_ids'=>[]]);
foreach(['issuer'=>$f['company'],'client'=>$client] as $kind=>$id) $prepare->save($kind,$id,$actor,['version'=>0,'nuit'=>$kind==='issuer'?'999999991':'999999992','address'=>'Rua de demonstração, 1','locality'=>'Localidade sintética','country'=>'Moçambique']);
@@ -43,12 +43,12 @@ final class InvoiceFixture
$select($actor);
return $f+compact('client','service','source','draft','profile','temporal','calculation','series','rule','today','start','end');
}
public static function additionalDraft(array $f): array
public static function additionalDraft(array $f,string $unitPrice='7850.00'): array
{
TestingDatabaseGuard::assertSafe(); $actor=$f['manager']->id; $validator=$f['reader']->id;
$select=function($id)use($f) { app(CompanyContext::class)->selectFor($id,$f['tenant'],$f['company'],$f['unit']); };
$select($actor); $w=app(ServiceWorkflow::class); $fiscal=app(ServiceFiscal::class); $invoices=app(ServiceInvoicing::class);
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação sintética concorrente','client_id'=>$f['client'],'currency'=>'MZN','lines'=>[['service_id'=>$f['service'],'quantity'=>'1','unit_price'=>'7850.00']]],(string)Str::uuid())['id'];
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação sintética concorrente','client_id'=>$f['client'],'currency'=>'MZN','lines'=>[['service_id'=>$f['service'],'quantity'=>'1','unit_price'=>$unitPrice]]],(string)Str::uuid())['id'];
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Documento sintético concorrente'],(string)Str::uuid())['id'];
app(DocumentPreparation::class)->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$f['start'],'period_end'=>$f['end'],'units'=>['período'],'payment_instruction_ids'=>[]]);
$temporal=$fiscal->propose($actor,['kind'=>'temporal','identifier'=>'TEST-CALC-'.$draft,'data'=>['profile_proposal_id'=>$f['profile'],'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>$f['start'],'reference_basis'=>'Referência explícita sintética.','period_start'=>$f['start'],'period_end'=>$f['end'],'event'=>'pending','evidence'=>'Prova concorrente isolada.']])['id'];
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Tests\Support;
use App\Modules\Services\ServiceInvoicing;
use App\Modules\Services\ServiceReceivables;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Str;
final class ReceivablesFixture
{
public static function create(?array $base=null): array
{
ServiceReceivables::guard();
// Existing approved calculation path: 862.07 + trunc(137.9312,2) = 1000.00.
// Only a synthetic parameter choice; never infers tax treatment from the receivable amount.
$f=InvoiceFixture::create($base,'862.07');
foreach(['manager'=>['view','prepare','allocate','correction_request'],'reader'=>['view','confirm','reconcile','correction_approve']] as $who=>$rights)
foreach($rights as $right) IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],'service_receivables.'.$right,$f['unit']);
app(CompanyContext::class)->selectFor($f['manager']->id,$f['tenant'],$f['company'],$f['unit']);
$si=app(ServiceInvoicing::class); $review=$si->review($f['draft'],$f['series'],$f['rule'],$f['manager']->id);
$invoice=$si->issue($f['draft'],$f['manager']->id,['series_id'=>$f['series'],'rule_id'=>$f['rule'],'review_hash'=>$review['review_hash']],(string)Str::uuid())['id'];
return $f+['invoice'=>$invoice];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Tests\Support;
use App\Models\User;
use App\Modules\Services\ServiceWorkflow;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class WorkshopFixture
{
public static function create(?array $base=null): array
{
WorkshopWorkflow::guard(); $f=$base??DocumentPreviewFixture::create();
$tech=User::create(['name'=>'Técnico Sintético Oficina','email'=>'tecnico-oficina@example.test','password'=>$f['password']]);
$reviewer=User::create(['name'=>'Verificador Sintético Oficina','email'=>'verificador-oficina@example.test','password'=>$f['password']]);
foreach([$tech,$reviewer] as $u) {
DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company']]);
IdentityFixture::grant($u->id,$f['tenant'],$f['company'],'company.view'); IdentityFixture::grant($u->id,$f['tenant'],$f['company'],'unit.view',$f['unit']);
}
foreach(['view','record'] as $r) IdentityFixture::grant($tech->id,$f['tenant'],$f['company'],'workshop.'.$r,$f['unit']);
foreach(['view','verify'] as $r) IdentityFixture::grant($reviewer->id,$f['tenant'],$f['company'],'workshop.'.$r,$f['unit']);
foreach(['view','open','assign','authorize'] as $r) IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],'workshop.'.$r,$f['unit']);
app(CompanyContext::class)->selectFor($f['manager']->id,$f['tenant'],$f['company'],$f['unit']);
$client=app(ServiceWorkflow::class)->save('clients',null,$f['manager']->id,['name'=>'Cliente Sintético da Oficina'],(string)Str::uuid())['id'];
return $f+['technician'=>$tech,'reviewer'=>$reviewer,'client'=>$client];
}
}