commit melhorias1

This commit is contained in:
2026-11-11 15:04:01 +02:00
parent 1d11a5e57b
commit cd9897df05
145 changed files with 10950 additions and 84 deletions
+25
View File
@@ -1,5 +1,30 @@
# SGF Tempo — instruções locais
- Entrega11/11: facturação multilinha QA na mesma8085, docs/SGF_MULTI_LINE_INVOICE_DELIVERY.md/provaJSON. Editor1–25serviços/acrescido-incluído/cálculo comum vinculado/revisão temporal distinta/PDF, doisTEST-MULTI-2026/1–2 de1160/base1000/imposto160, doisrecebíveis abertos total2320, doisGL29→31/24contas intactas, mesmo writer. Zero novosdireitos/grants/users/contas. PropriedadeComercial preservada, dependências financeiras qualificadas; APIComercial exige móduloComercial.143dirigidas+122preservação/96conjuntos/trêsbindings; concorrência5,09s empresaFORUPDATE/transactionid503→409apóslibertação/replay200/umdocumento/contador1, sóregressão(fixturesemrecebívelGLidentificada). Falhasparciais rascunho/emissão rollbackrecuperaçãoúnica.22comandos8085+2resultado guardadosprivados: NÃO repetir. Resultado parcialv5/v6mesmamappingv4/31GL/subtotal2286,21/dezporclassificar; antigos19/27e23/29CSVidênticos. Períodoreadonly44factos/0pendentes/débitoscréditosiguais/aberto. Caixa950/activo1200-100-1100/RH/Stock/IVA4/seisregistos/cacheintactos. UI21simuladas/PDF25linhas5páginasinspeccionadasnãoénavegador; vigência/datacorrente/zeroisento/misturasdescontos/POS/Excel/fiscalidadelegalnãoincluídos. Sem demos/SGC/publicação/candidata/checkpoint/aceitação; matrizv30incompleta, visual/carga/implantação separados. Propostasanterioresporautorizar sãohistóricas.
- Autorização expressa11/11: «Autorizar a facturação multilinha QA proposta», docs/SGF_MULTI_LINE_INVOICE_SCOPE.md/JSON. Implementar bloco completo UI/cálculo comum→emissãoQA/PDF→recebíveis→diário, dois documentos1160 cada/acrescido e incluído/regraQA16% existente, nova sérieTEST-MULTI2026 pelos mecanismos actuais;2recebíveis total2320/2GL29→31/24contas intactas/nenhum direito ou acesso novo. Direitos22nomes existentes SELECT-only verificados. Retira apenas a exclusão de emissão/dívida/GL deste recorte seleccionado, sem fiscalidade/operações reais/AT/envio/pagamentos/Stock/Caixa/RH/reabertura/publicação/checkpoint. Mesma8085/regressão, seisregistos/cache/todosartefactos anteriores preservados. Não pedir por campo/etapa; propostas anteriores por autorizar substituídas. Antes de instalar, provar todo bloco e preservar ramos antigos.
- Próxima fronteira concreta11/11: docs/SGF_MULTI_LINE_INVOICE_SCOPE.md/JSON, POR AUTORIZAR/NÃO IMPLEMENTADA. ServiceCalculation/e emissão ainda bloqueiam váriaslinhas e preço incluído; âmbito anterior SGF_MULTI_LINE_INCLUDED_SCOPE autoriza apenas cálculo e exclui expressamente factura/dívida/GL. Proposta agrupada editor/cabeçalho/grelha/totais/cálculo→emissão QA/PDF→recebíveis→diário;2documentos1160 cada(400+600 acrescido,464+696 incluído), regraQA16% existente/vigente, série novaTEST-MULTI2026,2recebíveis total2320,2GL novos29→31/mantém24contas/sem dinheiro. Nenhum direito/concessão novo; SELECT-only22nomes já presentes nos dois livros. Usar cliente/serviços QA existentes autorizados sem seisregistos; novaspreparações pelos writersactuais. Não retirar guardas nem emitir/contabilizar antes da decisão. Sem novasdemos/SGC/PUBLIC/operações reais/AT/envio/publicação/Stock/Caixa/RH/fecho/checkpoint. Cadaartefacto antigo permanece imutável, visual/carga/implantação separados.
- Entrega11/11: SGF-FIXED-ASSETS-INTEGRATED-QA-v1 na mesma8085, docs/SGF_FIXED_ASSETS_DELIVERY.md/prova JSON. Cadastro/plano/quota revistos, novoQA-ACTIVO-01 custo1200/residual0/vida12/admissão01Oct/quota31Oct100/líquido1100. Seis versões/recibos administrativos e doisGL novos(27→29), cinco contas(19→24); originais íntegros. Extensão tipada do MESMO writer/guardas SQL/pins autorizada, sem segundo diário. Correcção100→120/par atómico e abate120+1080; abate100+1100/fecho do recorte com harness financeiro isolado em rollback na regressão. Concorrênciaempresa FORUPDATE/transactionid5,24s503→409 pós-libertação/replay200/sem duplicação.141dirigidas+87preservação/78conjuntos/3bindings,13aritméticas reutilizadas. Cinco concessões QA por livro10total, nenhum perfil/user/PGrole/PUBLIC novo. Quatro comandos de correspondência/resultado novo:24contas catálogo/23com movimento/29GL/dez por classificar, corte01Jan–11Nov/subtotalconhecido286,21; antigo19/27CSV idêntico histórico.8085conferência read-only40factos/0pendentes/balanced; período aberto. Progresso/chaves privados assets-http-progress-private.json e assets-statements-http-progress-private.json: NÃO repetir12+4comandos nem origens antigas. Fonte financeira estável separada da revisão administrativa que invalida capturas pendentes. SSRlegado não terminou/não contado; novo15UI+HTTP/buildSHA aprovados, sem revisão visual. Matrizv29produto incompleto; legal/carga/implantação separados. Seis registos/cache/Caixa950/RH/Stock preservados, sem demos/SGC/publicação/candidata/checkpoint/aceitação.
- Referências visuais11/11 recebidas:15capturas em DOCs/Ecrans, docs/SGF_BILLING_SCREEN_REFERENCES.md. Primeiras12 relevantes(menu/listas/cadastros/grelha facturação/totais);13–15 aplicações alheias e duplicados não são requisitos adicionais. Aproveitar campos/percurso, não transcrever dados pessoais/NUIT/contactos/valores nem adoptar taxas comolei. Preservar menu horizontal/dropdown, fontes/estados/direitos; não inferir POS/Excel/campos novos implementados. É orientação durante o ciclo de activos, não cancelamento/substituição da tarefa nem aceitação visual do SGF.
- Autorização expressa11/11: «Autorizar o ciclo integrado de activos QA», docs/SGF_FIXED_ASSETS_SCOPE.md/JSON. Implementar todo o ciclo agrupado com extensão tipada do MESMO núcleo/pins, não segundo writer.3direitos/5grants por livro10total aos15/16/17existentes,5contas artificiais por livro(19→24),2GL novos8085(27→29) mantendooriginais,4comandos de correspondências/resultado parcial novo preservandoold19/27snapshot. Cadastro/custódia/plano/depreciação/relatórios/conferência; abate/correcção técnicaquota e fecho rollback sóregressão. QAactivo1200/residual0/vida12/Outubro100/líquido1100. Sem novasdemos/SGC/contas reais/dinheiro/dívida/submissão/fechoreal/publicação/checkpoint; preservar6registos/cache/Caixa950/HP16/RM6/trabalhoalheio. Indicações anteriores por autorizar são históricas; não pedir por campo/operação/teste.
- Continuação11/11: painel fiscal reconciliado viaFiscalCoveragev3, docs/SGF_COVERAGE_RECONCILIATION.md. RH/cálculosinternosQA reconhecidos, legalIRPS-INSS continua pendente, demonstraçõesparciais existentes/activos ausentes explícitos.7metadata+8HTTP,66conjuntos/3bindings intactos/HP16/RM6/GL27, zero grants/comandos negócios/cache/demos. Próxima decisão concreta docs/SGF_FIXED_ASSETS_SCOPE.md/JSON, **por autorizar/não implementada**: activosQA1200/residual0/vida12, quotaOutubro100/líquido1100, admissão01Oct/quota31Oct no mesmo núcleo/períodoQA2026aberto.3direitos fixed_assets.view15/16/17 prepare15 review16:5grants por livro10total, sem novas identidades;5contas artificiais/livro(19→24),2GL novos8085(27→29) preservando27originais, quatro comandos adicionais de correspondência/resultado parcial com antigo snapshot preservado/histórico. Extensão TIPADA do escritor/pins originais exige esta autorização explícita, não contornar com segundo writer/INSERT. Custódia/cadastro/plano/revisão/relatórios; abate/correcção técnica/fecho rollback na regressão. Nova origemQA por classificar; sem capital/dívida presumidos ou duplicaçãoStock/custos. AssetDepreciation puro13checks ainda não instalado; direitos ausentes SELECT-only ambos livros. Não conceder/aplicar antes da decisão. Preservarseis registos/cache/Caixa950/fontes; sem demos/SGC/pagamentos/fiscalidadelegal/publicação/aceitação/checkpoint, visual/carga/implantação separados. Matrizv28incompleta.
- Resumos legíveis RH entregues11/11, docs/SGF_HUMAN_RESOURCES_READABLE_DELIVERY.md, mesma8085 RH→pessoal→versão revista→Descarregar resumo legível desta versão. Derivação somente da versão congelada, CSV PT/UTF8BOM, formato v1/SHA próprio e referência ao original; originais preservados. Auth/contexto/área revalidados, tokens só header, cliente valida origem/formato/digest/contexto antes de guardar. Sem novos direitos/escritas de negócio8085/schema/perfis.58verificações dirigidas(17reg+11descarga+3handler simulado+27HTTP),66conjuntos+3bindings intactos/HP16/RM6/GL27/seis registos/cache preservados. Auditoria FUTURA de pessoal corrigida para contracts/attendance/leave e area; etiqueta anterior herdada de remunerações nos16eventos8085 não foi reescrita, histórico pleno continuaHPversions. Não repetir operações para testar rótulos: um evento de contrato apenas na regressão e revertido. Matrizv27incompleta; visual/carga/legal/implantação separados; sem demos/SGC/publicação/candidata/aceitação/checkpoint.
- Contratos/assiduidade/férias entregues11/11, docs/SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md, mesma8085 RH autónomo→Contratos, assiduidade e férias.9direitos/15grants por livro existente30total, mesmos3perfis/pins sem novas identidades. QA-CONTRATO-01/QA-COLABORADOR-01, planos09/10Nov480min, declaração09=480/10explicitamentevaziaapósgozo, orçamento fictício960/reserva→gozo480/disponível480.6origens16versões/recibos; não repetir comandos (hp-http-progress-private.json privado).233dirigidas+56compat afectada+78preservação, concorrência5,09s503 hostempresa FOR UPDATE/transactionid pg_blocking_pids→409 apóslibertação/nova preparação409 por insuficiência/replay200/um pedido aprovado.11versões suporte só na regressão.62conjuntos originais egrants antigos iguais+15exactos,3bindings/GL27/contas19/Caixa950/RMsixversões-recibos-CSV intactos/seis registos/cache.27fixturespaginação+26saldos+foreignunit/falha parcial/contrato salarial/recusa revertidos. Saldo actual separado do resultado congelado, leitura conjunta4SQL25/100 sem medição carga; recusa de proposta inviável permite NOVA conferência explícita com fontes/saldoactuais, nunca executa proposta. Ligação contratual opcional só em novaspreparações RM, prova regressão, sem retroatribuir contrato nem gerar novo salário8085. UI/MIME/SHA confirmados sem navegador/visual; flags root off/privadaQA on, sem demos/SGC/publicação/candidata/checkpoint/aceitação. Matrizv26produto amplo incompleto: legislação laboral/fiscal, IRPS/INSS/modelos/turnos gerais/benefícios/efeitos financeiros ainda pendentes; visual/carga/armazenamento/implantação separados. Propostas por autorizar anteriores são históricas.
- Autorização expressa11/11: «Autorizar o bloco e as30 concessões QA apresentadas», docs/SGF_HUMAN_RESOURCES_PERSONNEL_SCOPE.md/JSON. Implementar todo o bloco contratos/assiduidade/férias/conferência e ligação contratual explícita às novas preparações salariais apenas em regressão.9direitos/15grants por livro existente/30total, mesmos3perfisRH, sem novas identidades/pins. Preservar6versões/recibos salariais8085,seis registos/cache/GL27/contas19 e exemplos. Sem demos/SGC/efeitos financeiros/modelos legais/publicação/checkpoint. Indicações anteriores por autorizar substituídas; não pedir por campo/operação/teste.
- Pedido11/11 para contratos/assiduidade/férias no RH autónomo. Âmbito completo preparado `docs/SGF_HUMAN_RESOURCES_PERSONNEL_SCOPE.md`/JSON: contrato QA10000, planos09/10Nov480min, presença09, orçamento fictício960min/pedido-gozo10de480/saldo480, conferência parcial por pessoa e ligação contratual explícita só em futuras preparações salariais (prova regressão), preservando6versões/recibos salariais8085. Novos9direitos human_resources.contracts/attendance/leave × view/prepare/review; consultas3perfis, preparehr_prepare/reviewhr_review,15grants/livro30total nos perfis existentes, **por autorizar/não concedidos**; SELECT-only confirma ausência nos dois livros. Zero novas identidades/roles/pins alterados. PersonnelTime puro local22checks intervalos/desconhecidos/conflitos/saldo; não instalado, sem writers/schema/dados alterados. Não declarar funções novas entregues. Regra de novos poderes requer decisão antes de conceder; pedido de implementar não contém lista nova aprovada. Preservar8085/seis registos/cache/provas/GL27/contas19/Caixa950/fontes; sem demos/SGC/dívida/pagamento/GL/salário/contrato real/modelos legais/publicação/checkpoint. Não presumir férias legais960min nem classificar faltas automaticamente. Após aprovação, implementar todo bloco sem pedir por campo/rubrica/teste.
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Accounting;
use Brick\Math\{BigInteger,BigRational};
/** Pure proposed QA straight-line policy. No fiscal rate, asset, journal or clock source. */
final class AssetDepreciation
{
public static function quota(?string $cost,?string $residual,?int $months,int $month):array
{
abort_unless($month>=1&&$month<=600,422);foreach([$cost,$residual]as$value)if($value!==null)abort_unless(preg_match('/^[0-9]{1,12}\.[0-9]{2}$/D',$value)===1,422,'Valor documental deve ter duas casas.');if($months!==null)abort_unless($months>=1&&$months<=600&&$month<=$months,422);
if($cost===null||$residual===null||$months===null)return['complete'=>false,'quota_cents'=>null,'accumulated_cents'=>null,'carrying_cents'=>null,'exact_quota_cents'=>null,'difference_cents'=>null,'legal_tax_treatment'=>null];
$c=BigInteger::of(str_replace('.','',$cost));$r=BigInteger::of(str_replace('.','',$residual));abort_if($r->isGreaterThan($c),422,'Residual excede o custo.');$base=$c->minus($r);$standard=$base->quotient($months);$quota=$month===$months?$base->minus($standard->multipliedBy($months-1)):$standard;$accumulated=$month===$months?$base:$standard->multipliedBy($month);$exact=BigRational::of($base)->dividedBy($months)->simplified();return['complete'=>true,'cost_cents'=>(string)$c,'residual_cents'=>(string)$r,'life_months'=>$months,'month'=>$month,'quota_cents'=>(string)$quota,'accumulated_cents'=>(string)$accumulated,'carrying_cents'=>(string)$c->minus($accumulated),'exact_quota_cents'=>(string)$exact,'difference_cents'=>(string)$exact->minus($quota)->simplified(),'policy'=>'QA-straight-line-floor-months-final-residual-adjustment-v1','legal_tax_treatment'=>null];
}
}
+4 -3
View File
@@ -61,16 +61,17 @@ final class ComposedLedger
$authorize('view');
$isolation=fn()=>app(ComposedMaterialIsolation::class)->check($host,$native,$ledgerScope);
$stock=new StockValuation($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),$isolation);
$additional=function($kind,$id)use($host,$hostActor,$stock,$isolation){
$additional=function($kind,$id)use($host,$hostActor,$stock,$isolation,$native,$ledgerScope){
if(str_starts_with($kind,'stock.'))return $stock->source($kind,$id);
if(str_starts_with($kind,'workshop.')||str_starts_with($kind,'industry.'))$isolation();
return $this->withHost($host,$hostActor,function()use($hostActor,$kind,$id){
return $this->withHost($host,$hostActor,function()use($hostActor,$kind,$id,$native,$ledgerScope){
if(str_starts_with($kind,'assets.'))return app(FixedAssetLedgerSource::class)->source($hostActor,$kind,$id,$native,$ledgerScope);
if(str_starts_with($kind,'cash.'))return app(CashLedgerSource::class)->source($hostActor,$kind,$id);if(str_starts_with($kind,'workshop.'))return $kind==='workshop.direct_cost.validated'?app(CostLedgerSource::class)->source($hostActor,$id):app(CostLedgerSource::class)->correctionSource($hostActor,$kind,$id);
if(str_starts_with($kind,'industry.'))return $kind==='industry.cost.validated'?app(IndustrialLedgerSource::class)->source($hostActor,$id):app(IndustrialLedgerSource::class)->correctionSource($hostActor,$kind,$id);
return app(SalesLedgerSource::class)->source($hostActor,$kind,$id);
});
};
$period=function($p)use($host,$hostActor,$stock){$extra=$this->withHost($host,$hostActor,function()use($p,$hostActor){$facts=0;$pending=0;$h=hash_init('sha256');foreach([SalesLedgerSource::class,CostLedgerSource::class,IndustrialLedgerSource::class,CashLedgerSource::class] as $class){$v=app($class)->period($hostActor,$p);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];});$physical=$stock->period($p);return ['facts'=>$extra['facts']+$physical['facts'],'pending'=>$extra['pending']+$physical['pending'],'hash'=>hash('sha256',$extra['hash'].$physical['hash'])];};
$period=function($p)use($host,$hostActor,$stock,$native,$ledgerScope){$extra=$this->withHost($host,$hostActor,function()use($p,$hostActor,$native,$ledgerScope){$facts=0;$pending=0;$h=hash_init('sha256');foreach([SalesLedgerSource::class,CostLedgerSource::class,IndustrialLedgerSource::class,CashLedgerSource::class] as $class){$v=app($class)->period($hostActor,$p);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}if(config('fixed_assets.enabled')===true){$v=app(FixedAssetLedgerSource::class)->period($hostActor,$p,$native,$ledgerScope);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];});$physical=$stock->period($p);return ['facts'=>$extra['facts']+$physical['facts'],'pending'=>$extra['pending']+$physical['pending'],'hash'=>hash('sha256',$extra['hash'].$physical['hash'])];};
$engine=new PayableJournal($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),fn(int $id)=>$this->source($actor,$binding,$financialScope,$id),null,$additional,$period,$financialScope);
return $work($engine,$actor,$binding,$origin+['sgf_host_user'=>$hostUser,'sgf_host_scope'=>$scope,'binding_revision'=>$revision],$ledgerScope,$stock,fn(\Closure $read)=>$this->withHost($host,$hostActor,$read));
});
@@ -16,8 +16,8 @@ final class ComposedLedgerCandidates
$append(DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('invoices as i','i.id','=','s.local_source_id')->where(['o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0])->orderBy('o.id'),'supplier_payable.recognised',['o.id','o.principal_minor','i.number'],fn($o)=>['id'=>$o->id,'label'=>'Compra '.$o->number,'amount'=>$o->principal_minor]);
foreach(['ps_allocations'=>'allocation.confirmed','ps_reversals'=>'allocation.reversed'] as $table=>$type)$append(DB::table($table)->where($financial)->orderBy('id'),$type,['id','amount_minor'],fn($o)=>['id'=>$o->id,'label'=>($type==='allocation.confirmed'?'Afectação':'Reversão').' fornecedor #'.$o->id,'amount'=>$o->amount_minor]);
$hostRead(function()use($scope,$append){
$append(ModuleRecords::query('si_invoices')->where($scope)->orderBy('id'),'sales.invoice',['id','series_code','number'],fn($i)=>['id'=>$i->id,'label'=>'Venda '.$i->series_code.'/'.$i->number,'amount'=>null]);
foreach(['allocate'=>'receivable.allocated','reverse_allocation'=>'receivable.reversed','reduction'=>'receivable.reduced','reverse_reduction'=>'receivable.reduction_reversed'] as $event=>$type)$append(DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(['r.tenant_id'=>$scope['tenant_id'],'r.company_id'=>$scope['company_id'],'r.unit_id'=>$scope['unit_id']])->whereIn('r.invoice_id',ModuleRecords::query('si_invoices')->select('id'))->where('e.kind',$event)->orderBy('e.id'),$type,['e.id'],fn($e)=>['id'=>$e->id,'label'=>'Afectação/reversão cliente #'.$e->id,'amount'=>null]);
$append(\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderBy('id'),'sales.invoice',['id','series_code','number'],fn($i)=>['id'=>$i->id,'label'=>'Venda '.$i->series_code.'/'.$i->number,'amount'=>null]);
foreach(['allocate'=>'receivable.allocated','reverse_allocation'=>'receivable.reversed','reduction'=>'receivable.reduced','reverse_reduction'=>'receivable.reduction_reversed'] as $event=>$type)$append(DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(['r.tenant_id'=>$scope['tenant_id'],'r.company_id'=>$scope['company_id'],'r.unit_id'=>$scope['unit_id']])->whereIn('r.invoice_id',\App\Modules\Services\QualifiedInvoiceSource::query()->select('id'))->where('e.kind',$event)->orderBy('e.id'),$type,['e.id'],fn($e)=>['id'=>$e->id,'label'=>'Afectação/reversão cliente #'.$e->id,'amount'=>null]);
if(config('cash_qa.enabled')===true)foreach(['receipt'=>'cash.receipt_classified','expense'=>'cash.expense_confirmed'] as $kind=>$type)$append(DB::table('cash_events')->where($scope)->where(['action'=>'review','state'=>'approved','kind'=>$kind])->orderBy('id'),$type,['id'],fn($r)=>['id'=>$r->id,'label'=>'Caixa QA #'.$r->id,'amount'=>null]);
});
foreach(['gl_cost_sources'=>['order_id','workshop.direct_cost.validated','Custo de oficina'],'gl_industrial_sources'=>['lot_id','industry.cost.validated','Custo industrial']] as $table=>[$column,$type,$label])$append(DB::table($table)->where($scope)->orderBy('id'),$type,[$column],fn($o)=>['id'=>$o->$column,'label'=>$label.' #'.$o->$column,'amount'=>null]);
@@ -36,7 +36,7 @@ final class ComposedLedgerController
{
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','accounts_page'=>'sometimes|integer|min:1|max:100000','accounts_per_page'=>'sometimes|integer|in:25,100','account_q'=>'sometimes|nullable|string|max:80','source_page'=>'sometimes|integer|min:1|max:100000','source_per_page'=>'sometimes|integer|in:25,100','source_kind'=>'sometimes|in:'.implode(',',ComposedLedgerCandidates::KINDS),'period_key'=>'sometimes|string|max:80','account_id'=>'sometimes|nullable|integer|min:1','obligation_id'=>'sometimes|integer|min:1','sales_invoice_id'=>'sometimes|integer|min:1','cost_order_id'=>'sometimes|integer|min:1','industrial_lot_id'=>'sometimes|integer|min:1']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);if(($d['account_id']??null)===null)unset($d['account_id']);$rights=$this->rights();
return $ledger->run('view',function($engine,$actor,$binding,$origin,$scope,$stock,$hostRead)use($r,$d,$rights){
if(isset($d['sales_invoice_id']))abort_unless($hostRead(fn()=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($scope)->where('id',$d['sales_invoice_id'])->exists()),403,'Documento fora do contexto e módulo.');
if(isset($d['sales_invoice_id']))abort_unless($hostRead(fn()=>\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->where('id',$d['sales_invoice_id'])->exists()),403,'Documento fora do contexto e módulo.');
$data=$engine->listing(array_diff_key($d,['source_page'=>true,'source_per_page'=>true,'source_kind'=>true,'accounts_page'=>true,'accounts_per_page'=>true,'account_q'=>true]));$data['cycle']=$engine->cycleListing((int)($d['page']??1));$data['permissions']=$rights;$data['current_actor']=$actor->id;$data['sgf_scope']=$origin['sgf_host_scope'];
$data=array_merge($data,app(ComposedLedgerCandidates::class)->read($d,$actor,$scope,$stock,$hostRead));
$accounts=DB::table('gl_accounts')->where($scope);if(($d['account_q']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['account_q']).'%';$accounts->where(fn($q)=>$q->whereRaw("code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("label ILIKE ? ESCAPE '!'",[$needle]));}
@@ -0,0 +1,28 @@
<?php
namespace App\Modules\Accounting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
/** Preview via rollback, confirm through the original writer, receipt through its original store. */
final class FixedAssetJournalController
{
private function hash(array $a):string{return hash('sha256',Payables::canonical($a));}
private function validate(array $v):array{abort_if(array_diff(array_keys($v),['action','data']),422);return Validator::make($v,['action'=>'required|in:effect-prepare,validate,post,correct-prepare,correct-validate,correct-post','data'=>'required|array'])->validate();}
private function run(Request $r,string $right,\Closure $fn){$actor=$r->user();app(FixedAssets::class)->scope($actor->id);try{return app(ComposedLedger::class)->run($right,fn($e,$a,$b,$o,$s,$stock,$hostRead)=>$fn($e,DB::connection(),$s,$hostRead));}catch(\DomainException $e){abort(409,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}}
private function right(string $action):string{return $action==='effect-prepare'?'prepare':str_replace('-','_',$action);}
private function qualified(object $db,array $s,array $d):void{
$v=$d['data'];if($d['action']==='effect-prepare'){abort_unless(in_array($v['effect_type']??null,['assets.admitted','assets.depreciation','assets.writeoff'],true),422);return;}
if(in_array($d['action'],['validate','post'],true)){$p=$db->table('gl_proposals')->where($s)->where('id',$v['proposal_id']??0)->first();abort_unless($p&&str_starts_with(json_decode($p->snapshot,true)['source']['effect_type'],'assets.'),403);return;}
if($d['action']==='correct-prepare')$id=$v['journal_id']??0;else{$g=$db->table('gl_corrections')->where($s)->where('id',$v['correction_id']??0)->first();abort_unless($g,403);$id=$g->original_journal_id;}$j=$db->table('gl_journals')->where($s)->where('id',$id)->first();abort_unless($j&&json_decode($j->snapshot,true)['source']['effect_type']==='assets.depreciation',403);
}
private function impact(object $db,array $s,array $result):array{
$ids=[];if(isset($result['proposal_id']))$ids[]=$result['proposal_id'];if(isset($result['correction_id'])){$g=$db->table('gl_corrections')->where($s)->where('id',$result['correction_id'])->first();$ids=[$g->reverse_proposal_id,$g->replacement_proposal_id];}
$snap=[];foreach($ids as$id)$snap[]=json_decode($db->table('gl_proposals')->where($s)->where('id',$id)->value('snapshot'),true);return ['state'=>$result['state'],'snapshots'=>$snap,'journal_count'=>isset($result['journal_id'])?1:(isset($result['reverse_journal_id'])?2:0),'payment_created'=>false];
}
private function simulate($e,object $db,array $s,array $d,string $key):array{
$this->qualified($db,$s,$d);$db->beginTransaction();try{$result=$e->command($d['action'],$d['data'],$key);return$this->impact($db,$s,$result);}finally{$db->rollBack();}
}
public function preview(Request $r){abort_if(strlen(json_encode($r->all()))>16384,422);$d=$this->validate($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);return$this->run($r,$this->right($d['action']),function($e,$db,$s)use($r,$d,$key){$impact=$this->simulate($e,$db,$s,$d,$key);$c=['actor'=>$r->user()->id,'scope'=>$s,'generation'=>$r->session()->get('company_context.token'),'observed'=>time(),'input'=>$d,'key'=>$key,'impact'=>$impact];return response()->json(['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>$impact])->header('Cache-Control','no-store');});}
public function confirm(Request $r){abort_if(array_diff(array_keys($r->all()),['preview']),422);$r->validate(['preview'=>'required|string|max:131072']);try{$c=json_decode(Crypt::decryptString($r->input('preview')),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->validate($c['input']??[]);return$this->run($r,$this->right($d['action']),function($e,$db,$s)use($r,$c,$d){abort_unless($c['actor']===$r->user()->id&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===$r->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$this->qualified($db,$s,$d);$old=$e->receipt($c['key']);if($old){abort_unless(hash_equals($old['request_hash'],$this->hash([$d['action'],$d['data']])),409);return response()->json($old['result']+['replayed'=>true])->header('Cache-Control','no-store');}$impact=$this->simulate($e,$db,$s,$d,$c['key']);abort_unless($this->hash($impact)===$this->hash($c['impact']),409,'Impacto ou origem mudou; confira novamente.');$out=$e->command($d['action'],$d['data'],$c['key']);return response()->json($out)->header('Cache-Control','no-store');});}
public function index(Request $r){$v=$r->validate(['asset_key'=>'required|uuid','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($v)),422);$hostActor=$r->user();return$this->run($r,'view',function($e,$db,$s,$hostRead)use($r,$v,$hostActor){$key=$v['asset_key'];$hostRead(fn()=>app(FixedAssets::class)->approved($s,$key,'asset'));$p=$db->table('gl_proposals')->where($s)->where('snapshot->source->asset_key',$key)->orderByDesc('id')->paginate($v['per_page']??25,['*'],'page',$v['page']??1);$ids=$p->pluck('id');$reviews=$db->table('gl_reviews')->where($s)->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$journals=$db->table('gl_journals')->where($s)->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$items=[];foreach($p as$row)$items[]=['id'=>$row->id,'revision'=>$row->revision,'prepared_by'=>$row->prepared_by,'snapshot'=>json_decode($row->snapshot,true),'review'=>$reviews->get($row->id),'journal'=>$journals->get($row->id)];$p->setCollection(collect($items));$groups=$db->table('gl_corrections')->where($s)->where(fn($q)=>$q->whereIn('reverse_proposal_id',$ids)->orWhereIn('replacement_proposal_id',$ids))->orderByDesc('id')->get()->map(function($g)use($db,$s){$g->applied=$db->table('gl_correction_results')->where($s)->where('correction_id',$g->id)->exists();$g->approved=$db->table('gl_reviews')->where($s)->whereIn('proposal_id',[$g->reverse_proposal_id,$g->replacement_proposal_id])->where('decision','approved')->count()===2;return$g;});$balance=$hostRead(fn()=>app(FixedAssets::class)->balance($s,$key,$db));return response()->json(['records'=>$p->toArray(),'balance'=>$balance,'corrections'=>$groups])->header('Cache-Control','no-store');});}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Accounting;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\Payables;
/** Read-only qualified source adapter. Never creates a journal or debt. */
final class FixedAssetLedgerSource
{
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
public function source(User $actor,string $kind,int $id,object $native,array $scope,bool $historical=false):array{
$service=app(FixedAssets::class);abort_unless($service->scope($actor->id)===$scope,403);
if(in_array($kind,['assets.depreciation.reversal','assets.depreciation.replacement'],true)){
$j=$native->table('gl_journals')->where($scope)->where('id',$id)->first()??abort(403);$original=json_decode($j->snapshot,true);abort_unless($original['source']['effect_type']==='assets.depreciation',409,'Só a quota original admite esta correcção.');
$base=$this->source($actor,'assets.depreciation',(int)$original['source']['source_id'],$native,$scope);abort_unless($base['recognition_revision']!==$original['source']['recognition_revision'],409,'Não existe nova quota revista.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='assets.depreciation.reversal'?(int)$j->debit_minor:$base['amount_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j)]);
}
abort_unless(in_array($kind,['assets.admitted','assets.depreciation','assets.writeoff'],true),422);$initial=$service->byId($scope,$id);abort_unless($initial&&$initial['state']==='approved',403);$expected=match($kind){'assets.admitted'=>'asset','assets.depreciation'=>'charge','assets.writeoff'=>'disposal'};abort_unless($initial['kind']===$expected,409);$current=$service->approved($scope,$initial['record_key'],$expected);$asset=$expected==='asset'?$current:$service->approved($scope,$current['data']['input']['asset_key'],'asset');$a=$asset['data']['input'];abort_unless($a['cost']!==null&&$a['residual']!==null&&$a['available_on']!==null,409,'Dados financeiros do activo indeterminados.');
$balance=$service->balance($scope,$asset['record_key'],$native);$amount=(int)str_replace('.','',$a['cost']);$date=$a['available_on'];$facts=['asset'=>FixedAssets::financial($asset)];$extra=[];
if($expected==='charge'){
abort_unless($balance['cost_minor']===$amount&&($historical||!$balance['disposed']),409,'Admissão actual contabilizada obrigatória.');$p=$service->approved($scope,$current['data']['input']['plan_key'],'plan');abort_unless(($historical||$p['data']['status']==='active')&&$p['data']['input']['asset_key']===$asset['record_key'],409,'Plano suspenso ou incompatível.');abort_unless($current['data']['dependencies']['plan']['data']['input']['life_months']===$p['data']['input']['life_months']&&FixedAssets::financial($current['data']['dependencies']['asset'])===FixedAssets::financial($asset),409,'Plano/dados alterados; confira a quota.');
$c=$current['data']['calculation'];abort_unless($c['complete'],409);$amount=(int)$c['quota_cents'];$month=$current['data']['input']['month'];$date=(new \DateTimeImmutable($month.'-01'))->modify('last day of this month')->format('Y-m-d');for($n=1;$n<$c['month'];$n++){$previous=(new \DateTimeImmutable(substr($a['available_on'],0,7).'-01'))->modify('+'.($n-1).' months')->format('Y-m');abort_unless(isset($balance['charges'][$previous]),409,'Quota anterior não contabilizada.');}
if(!$historical&&!isset($balance['charges'][$month]))abort_unless($balance['accumulated_minor']===(int)$c['accumulated_cents']-$amount&&$balance['accumulated_minor']+$amount<=(int)$c['cost_cents']-(int)$c['residual_cents'],409,'Acumulado não corresponde às quotas anteriores revistas; exige conferência/correcção.');
$facts+=['charge_version'=>$current['id'],'plan'=>$current['data']['dependencies']['plan']['id'],'calculation'=>$c];$extra=['charge_key'=>$current['record_key'],'charge_month'=>$month];
}
if($expected==='disposal'){
$pending=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($scope)),array_values($scope)))->whereIn('v.kind',['plan','charge'])->whereRaw("v.data->'input'->>'asset_key'=?",[$asset['record_key']])->where('v.state','prepared')->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->whereColumn('n.record_key','v.record_key')->whereColumn('n.version','>','v.version');})->exists();abort_if($pending,409,'Plano ou quota aguarda decisão; confira antes do abate.');
foreach($native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->where('snapshot->source->effect_type','assets.depreciation')->orderBy('id')->get()as$q){$snap=json_decode($q->snapshot,true)['source'];$currentSource=$this->source($actor,'assets.depreciation',(int)$snap['source_id'],$native,$scope);if($snap['recognition_revision']!==$currentSource['recognition_revision']){$pair=$native->table('gl_correction_results')->where($scope)->where('original_journal_id',$q->id)->first();$replacement=$pair?$native->table('gl_journals')->where($scope)->where('id',$pair->replacement_journal_id)->first():null;abort_unless($replacement&&(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)===$currentSource['recognition_revision'],409,'Quota exige correcção aplicada antes do abate.');}}
abort_unless($balance['cost_minor']===$amount&&!$balance['disposed'],409,'Activo sem custo aplicado ou já abatido.');$frozen=$current['data']['calculation'];abort_unless($this->hash($balance)===$this->hash($frozen),409,'Saldo alterado; confira novo abate.');$date=$current['data']['input']['effect_on'];$last=$native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->max('journal_on');abort_unless($last===null||$last<=$date,409,'Abate anterior a movimentos.');$facts+=['disposal_version'=>$current['id'],'balance'=>$balance];$extra=['cost_minor'=>$balance['cost_minor'],'accumulated_minor'=>$balance['accumulated_minor'],'carrying_minor'=>$balance['carrying_minor']];
}
abort_unless($amount>0,409,'Efeito nulo não cria lançamento.');$observedKeys=array_values(array_unique(array_filter([$asset['record_key'],$current['record_key'],$current['data']['input']['plan_key']??null])));$observed=DB::table('fa_versions')->where($scope)->whereIn('record_key',$observedKeys)->select('record_key')->selectRaw('max(version) as version')->groupBy('record_key')->orderBy('record_key')->get()->map(fn($r)=>(array)$r)->all();$extra['administrative_revision']=$this->hash($observed);$first=DB::table('fa_versions')->where($scope)->where(['record_key'=>$current['record_key'],'state'=>'approved'])->orderBy('version')->value('id');return $extra+['effect_type'=>$kind,'source_id'=>(int)$first,'obligation_id'=>(int)$first,'asset_key'=>$asset['record_key'],'origin_key'=>$this->hash(['scope'=>$scope,'asset'=>$asset['record_key'],'kind'=>$kind,'month'=>$extra['charge_month']??null]),'host_database'=>DB::selectOne('SELECT current_database() d')->d,'permitted_period_key'=>'QA-2026-INTEGRADO','effect_on'=>$date,'recognition_revision'=>$this->hash($facts),'document_revision'=>$this->hash($facts),'amount_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false];
}
public function period(User $actor,array $period,object $native,array $s):array{
app(FixedAssets::class)->scope($actor->id);$h=hash_init('sha256');$facts=0;$pending=0;$rows=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($s)),array_values($s)))->where('v.state','approved')->whereIn('v.kind',['asset','charge','disposal'])->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.record_key','v.record_key')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->where('n.state','approved')->whereColumn('n.version','>','v.version');});
foreach($rows->orderBy('v.id')->cursor()as$r){$d=json_decode($r->data,true);if($d['status']==='cancelled')continue;$on=match($r->kind){'asset'=>$d['input']['available_on'],'charge'=>$d['input']['month'].'-01','disposal'=>$d['input']['effect_on']};if($on!==null&&($on<$period['starts_on']||$on>$period['ends_on']))continue;$facts++;hash_update($h,$r->data);$kind=match($r->kind){'asset'=>'assets.admitted','charge'=>'assets.depreciation','disposal'=>'assets.writeoff'};
$j=$native->table('gl_journals')->where($s)->where('snapshot->source->effect_type',$kind)->where('snapshot->source->asset_key',$r->kind==='asset'?$r->record_key:$d['input']['asset_key']);if($r->kind==='charge')$j->where('snapshot->source->charge_key',$r->record_key);$j=$j->first();
if($r->kind==='disposal'&&$j)continue;
try{$source=$this->source($actor,$kind,(int)$r->id,$native,$s,true);if(!$j){$pending++;continue;}$snap=json_decode($j->snapshot,true)['source'];if($snap['recognition_revision']!==$source['recognition_revision']){$pair=$native->table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();$replacement=$pair?$native->table('gl_journals')->where($s)->where('id',$pair->replacement_journal_id)->first():null;if(!$replacement||(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)!==$source['recognition_revision'])$pending++;}}
catch(\DomainException|\Symfony\Component\HttpKernel\Exception\HttpException $e){$pending++;}
}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Modules\Accounting;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
/** Reviewed administrative assets. Accounting effects exclusively through the common journal. */
final class FixedAssets
{
public const POLICY='SGF-FIXED-ASSETS-INTEGRATED-QA-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function json(array $a):string{return Payables::canonical($a);}
private function hash(array $a):string{return hash('sha256',$this->json($a));}
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
public function scope(int $actor,string $action='view'):array{
abort_unless(config('fixed_assets.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='accounting',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.'.$r,$s['unit_id']);
$m=DB::table('fa_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('fixed_assets.schema_sha256')),503,'Schema de activos não conferido.');
$boot=config('database.connections.receipt_native');if($boot===null){$ready=app(ComposedLedger::class)->run('view',fn()=>true);abort_unless($ready===true,503,'Livro ocupado; tente novamente com a mesma operação.');}$native=DB::connection('receipt_native');\Tempo\Finance\GeneralLedger\LedgerSchema::assertSynthetic($native);$m=$native->table('gl_asset_extension_versions')->where('version',1)->first();abort_unless($m&&hash_equals($m->source_hash,(string)config('fixed_assets.native_schema_sha256'))&&$native->table('gl_asset_books')->where($s)->where('host_database',DB::connection()->getDatabaseName())->exists(),503,'Origem contabilística de activos não conferida.');return$s;
}
private function identity(int $actor,array $s):string{$u=DB::table('users')->where('id',$actor)->first(['id','name','email']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);}
private function lock(int $actor,array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();}
private function rows(array $s){return DB::table('fa_versions')->where($s);}
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$k)$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return$a;}
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
public function effective(array $s,string $key):?array{return $this->latest($s,$key,true);}
public function byId(array $s,int $id):?array{return $this->row($this->rows($s)->where('id',$id)->first());}
public function approved(array $s,string $key,string $kind):array{$a=$this->effective($s,$key);abort_unless($a&&$a['kind']===$kind&&$a['data']['status']!=='cancelled',409,'Origem revista activa obrigatória.');return $a;}
private function input(array $v):array{
if(is_string($v['reason']??null))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','kind'=>'required|in:asset,plan,charge,disposal','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];$d['record_key']=strtolower($d['record_key']);
if($d['action']==='review'){$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;}
$money='present|nullable|string|regex:/^[0-9]{1,12}\\.[0-9]{2}$/D';
$rules=match($d['kind']){
'asset'=>['code'=>'required|string|regex:/^QA-[A-Z0-9-]{2,60}$/D','description'=>'required|string|min:5|max:300','origin_reference'=>'required|string|regex:/^QA-[A-Z0-9-]{2,80}$/D','cost'=>$money,'residual'=>$money,'available_on'=>'present|nullable|date_format:Y-m-d','location'=>'required|string|max:100','custodian'=>'required|string|max:100'],
'plan'=>['asset_key'=>'required|uuid','life_months'=>'present|nullable|integer|min:1|max:600','status'=>'required|in:active,suspended'],
'charge'=>['asset_key'=>'required|uuid','plan_key'=>'required|uuid','month'=>'required|date_format:Y-m'],
'disposal'=>['asset_key'=>'required|uuid','effect_on'=>'required|date_format:Y-m-d']};
$d['data']=$this->exact($d['data'],['operation'=>'required|in:establish,amend,cancel,correct_plan','evidence'=>'required|string|min:10|max:1000']+$rules);foreach(['asset_key','plan_key']as$k)if(isset($d['data'][$k]))$d['data'][$k]=strtolower($d['data'][$k]);if(isset($d['data']['life_months']))$d['data']['life_months']=(int)$d['data']['life_months'];return$d;
}
private function basis(int $actor,array $s,array $d):array{
$last=$this->latest($s,$d['record_key']);$data=$d['action']==='prepare'?$d['data']:($last['data']['input']??[]);$deps=[];
if($d['kind']!=='asset'){$deps['asset']=$this->approved($s,$data['asset_key']??'','asset');$deps['ledger']=$this->balance($s,$data['asset_key']);}
else $deps['ledger']=$this->balance($s,$d['record_key']);
if($d['kind']==='charge')$deps['plan']=$this->approved($s,$data['plan_key']??'','plan');
return['last'=>$last,'effective'=>$this->effective($s,$d['record_key']),'dependencies'=>$deps,'actor_identity'=>$this->identity($actor,$s)];
}
public function balance(array $s,string $key,?object $db=null):array{
$db??=DB::connection('receipt_native');$q=$db->table('gl_journals')->where($s)->where('snapshot->source->asset_key',$key);$rows=(clone$q)->orderBy('id')->get(['id','snapshot','debit_minor']);$cost=0;$acc=0;$disposed=false;$charges=[];
foreach($rows as$j){$v=json_decode($j->snapshot,true);$kind=$v['source']['effect_type'];if($kind==='assets.admitted')$cost+=(int)$j->debit_minor;if(in_array($kind,['assets.depreciation','assets.depreciation.replacement'],true))$acc+=(int)$j->debit_minor;if($kind==='assets.depreciation.reversal')$acc-=(int)$j->debit_minor;if($kind==='assets.writeoff')$disposed=true;if($kind==='assets.depreciation')$charges[$v['source']['charge_month']]=$j->id;}
return['cost_minor'=>$cost,'accumulated_minor'=>$acc,'carrying_minor'=>$disposed?0:$cost-$acc,'disposed'=>$disposed,'charges'=>$charges,'journal_ids'=>$rows->pluck('id')->all()];
}
public static function financial(array $a):array{return array_intersect_key($a['data']['input'],array_flip(['code','origin_reference','cost','residual','available_on']));}
private function plan(int $actor,array $s,array $d,array $b):array{
$last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$effective['data']??null;
if($d['action']==='prepare'){$input=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'fixed_assets.prepare',$s['unit_id']);$input=$last['data']['input'];abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Dependências mudaram. Prepare novamente.');}
$op=$input['operation'];if($op==='establish')abort_if($effective,409,'Registo já aprovado. Use correcção.');else abort_unless($old&&$old['status']!=='cancelled',409,'Exige registo activo já aprovado.');
$kind=$d['kind'];$dep=$b['dependencies'];$balance=$dep['ledger'];$status=$op==='cancel'?'cancelled':'active';$calculation=null;
if($op==='correct_plan')abort_unless($kind==='plan'&&$old,422);
if($kind==='asset'){
if($old)foreach(['code','origin_reference']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Código/origem imutáveis.');
abort_if($this->rows($s)->where('kind','asset')->where('record_key','<>',$d['record_key'])->where(fn($q)=>$q->whereRaw("data->'input'->>'code'=?",[$input['code']])->orWhereRaw("data->'input'->>'origin_reference'=?",[$input['origin_reference']]))->exists(),409,'Código ou origem já usados.');
if($balance['journal_ids']){abort_if($op==='cancel',409,'Activo contabilizado não se cancela; use abate.');foreach(['cost','residual','available_on']as$k)abort_unless($old['input'][$k]===$input[$k],409,'Custo/residual/data contabilizados imutáveis.');}
if($input['cost']!==null&&$input['residual']!==null)AssetDepreciation::quota($input['cost'],$input['residual'],1,1);
if($op==='cancel')abort_if($this->rows($s)->where('kind','<>','asset')->whereRaw("data->'input'->>'asset_key'=?",[$d['record_key']])->exists(),409,'Activo com dependências.');
}else{
abort_if($balance['disposed'],409,'Activo já abatido.');if($old)abort_unless($input['asset_key']===$old['input']['asset_key'],409,'Activo imutável.');
if(in_array($kind,['plan','disposal'],true))abort_if($this->rows($s)->where('kind',$kind)->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'asset_key'=?",[$input['asset_key']])->where('state','approved')->exists(),409,'Reutilize o plano ou abate existente.');
if($kind==='plan'){$status=$input['status'];abort_if($op==='cancel',422,'Suspenda explicitamente o plano.');if($old&&$balance['charges']&&$input['life_months']!==$old['input']['life_months']){abort_unless($op==='correct_plan',409,'Quotas aplicadas: exige plano de correcção explícito.');abort_unless(count($balance['charges'])===1,409,'Várias quotas aplicadas exigem tratamento coordenado fora deste recorte.');}}
if($kind==='charge'){
$a=$dep['asset']['data']['input'];$p=$dep['plan']['data'];abort_unless($p['input']['asset_key']===$input['asset_key']&&$p['status']==='active',409,'Plano suspenso ou de outro activo.');abort_unless($a['available_on']!==null,409,'Entrada em uso desconhecida.');$begin=$input['month'].'-01';$available=substr($a['available_on'],0,7).'-01';$delta=(int)substr($begin,0,4)*12+(int)substr($begin,5,2)-((int)substr($available,0,4)*12+(int)substr($available,5,2))+1;abort_unless($delta>=1,409,'Competência anterior à entrada em uso.');$calculation=AssetDepreciation::quota($a['cost'],$a['residual'],$p['input']['life_months'],$delta);if($old)foreach(['month','plan_key']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Competência/plano imutáveis.');
abort_if($this->rows($s)->where('kind','charge')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'asset_key'=? AND data->'input'->>'month'=?",[$input['asset_key'],$input['month']])->exists(),409,'Competência já registada.');abort_if($op==='cancel'&&isset($balance['charges'][$input['month']]),409,'Quota aplicada exige par de correcção.');
if($d['action']==='review'&&$d['data']['decision']==='approved'&&$op!=='cancel')abort_unless($calculation['complete'],409,'Custo/residual/vida desconhecidos.');
}
if($kind==='disposal'){abort_unless($balance['cost_minor']>0,409,'Admissão contabilizada obrigatória.');abort_unless($input['effect_on']>=$dep['asset']['data']['input']['available_on'],409);$calculation=$balance;}
}
$data=['input'=>$input,'status'=>$status,'dependencies'=>$dep,'calculation'=>$calculation,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;
$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'payments'=>0,'journals'=>0,'accounting_requires_separate_decisions'=>true,'legal_tax_treatment'=>null];return['kind'=>$kind,'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
}
public function preview(int $actor,array $input,string $key):array{$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['action']);$b=$this->basis($actor,$s,$d);$out=$this->plan($actor,$s,$d,$b);$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($c)),'impact'=>$out['impact']];});}
public function confirm(int $actor,string $token):array{
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('fa_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('fa_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('fa_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('fixed_assets.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
}
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$old=DB::table('fa_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];}
public function listing(int $actor,array $input):array{
$s=$this->scope($actor);$d=$this->exact($input,['kind'=>'required|in:asset,plan,charge,disposal','record_key'=>'sometimes|uuid','month'=>'sometimes|date_format:Y-m','state'=>'sometimes|in:prepared,approved,refused','search'=>'sometimes|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$this->rows($s)->where('kind',$d['kind']);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403);}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('fa_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'fa_versions.'.$k);$n->whereColumn('next.version','>','fa_versions.version');});
if(isset($d['month']))$q->whereRaw("data->'input'->>'month'=?",[$d['month']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("concat_ws(' ',data->'input'->>'code',data->'input'->>'description',data->'input'->>'asset_key',data->'input'->>'month') ILIKE ? ESCAPE '!'",[$needle]);}
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$a=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('fa_versions as e');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('e.'.$k,'fa_versions.'.$k);$n->where('e.state','approved')->whereColumn('e.version','>','fa_versions.version');})->get()->keyBy('record_key');
$items=[];foreach($page->items()as$r){$row=$this->row($r);$row['effective']=$this->row($a->get($r->record_key));$items[]=$row;}$page->setCollection(collect($items));return['policy'=>self::POLICY,'kind'=>$d['kind'],'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.review',$s['unit_id'])],'synthetic_only'=>true,'legal_tax_treatment'=>null];});
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Modules\Accounting;
use Illuminate\Http\Request;
final class FixedAssetsController
{
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Processamento de activos ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
public function index(Request $r,FixedAssets $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
public function preview(Request $r,FixedAssets $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function confirm(Request $r,FixedAssets $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,FixedAssets $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
}
+1 -1
View File
@@ -20,7 +20,7 @@ final class LedgerComposition
return DB::transaction(function()use($user,$options,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$ledger=app(GeneralLedger::class);$book=$ledger->listing($user,['page'=>(int)($options['journals_page']??1),'period_key'=>$options['period_key']??'QA-2026-10'],false);$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$permissions=app(Permissions::class);$can=fn($right)=>$permissions->has($user->id,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);$panels=['sales'=>['allowed'=>false],'costs'=>['allowed'=>false],'suppliers'=>['allowed'=>false]];
if($can('service_invoices.view')&&$can('service_receivables.view')){
$this->requireTables(['si_invoices','sr_records','sr_effects','gl_sales_capability']);$p=ModuleRecords::query('si_invoices')->where($scope)->orderBy('id')->paginate(25,['id','series_code','number','issued_at'],'invoices_page',(int)($options['invoices_page']??1));$ids=array_column($p->items(),'id');$posted=$this->postings($scope,'sales.invoice','invoice_id',$ids);$records=DB::table('sr_records')->where($scope)->whereIn('invoice_id',$ids)->where('kind','obligation')->get(['id','invoice_id'])->keyBy('invoice_id');$opened=DB::table('sr_effects')->whereIn('record_id',$records->pluck('id')->all())->where('kind','open')->pluck('record_id')->all();$rows=[];foreach($p->items() as $i){$ob=$records->get($i->id);$rows[]=['invoice_id'=>$i->id,'number'=>$i->series_code.'/'.$i->number,'issued_at'=>$i->issued_at,'confirmed_opening'=>$ob&&in_array($ob->id,$opened,true),'original_journal_id'=>$posted[$i->id]??null];}$panels['sales']=$this->page($p,$rows);
$this->requireTables(['si_invoices','sr_records','sr_effects','gl_sales_capability']);$p=\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderBy('id')->paginate(25,['id','series_code','number','issued_at'],'invoices_page',(int)($options['invoices_page']??1));$ids=array_column($p->items(),'id');$posted=$this->postings($scope,'sales.invoice','invoice_id',$ids);$records=DB::table('sr_records')->where($scope)->whereIn('invoice_id',$ids)->where('kind','obligation')->get(['id','invoice_id'])->keyBy('invoice_id');$opened=DB::table('sr_effects')->whereIn('record_id',$records->pluck('id')->all())->where('kind','open')->pluck('record_id')->all();$rows=[];foreach($p->items() as $i){$ob=$records->get($i->id);$rows[]=['invoice_id'=>$i->id,'number'=>$i->series_code.'/'.$i->number,'issued_at'=>$i->issued_at,'confirmed_opening'=>$ob&&in_array($ob->id,$opened,true),'original_journal_id'=>$posted[$i->id]??null];}$panels['sales']=$this->page($p,$rows);
}
if($can('workshop.view')&&$can('direct_costs.view')){
$this->requireTables(['gl_cost_sources','wo_orders','dc_calculations','dc_decisions']);$p=DB::table('gl_cost_sources')->where($scope)->orderBy('order_id')->paginate(25,['order_id','period_key'],'costs_page',(int)($options['costs_page']??1));$ids=array_column($p->items(),'order_id');$calculations=DB::table('dc_calculations as c')->where(array_combine(array_map(fn($k)=>'c.'.$k,array_keys($scope)),array_values($scope)))->whereIn('c.order_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('dc_calculations as n')->whereColumn('n.order_id','c.order_id')->whereColumn('n.version','>','c.version'))->get(['c.id','c.order_id','c.version'])->keyBy('order_id');$decisions=DB::table('dc_decisions')->whereIn('calculation_id',$calculations->pluck('id')->all())->get(['calculation_id','decision'])->keyBy('calculation_id');$posted=$this->postings($scope,'workshop.direct_cost.validated','cost_order_id',$ids);$results=DB::table('gl_correction_results')->where($scope)->whereIn('original_journal_id',array_values($posted))->get(['id','original_journal_id'])->keyBy('original_journal_id');$rows=[];foreach($p->items() as $m){$calc=$calculations->get($m->order_id);$j=$posted[$m->order_id]??null;$rows[]=['order_id'=>$m->order_id,'period_key'=>$m->period_key,'calculation_id'=>$calc?->id,'calculation_version'=>$calc?->version,'recorded_decision'=>$calc?$decisions->get($calc->id)?->decision:null,'original_journal_id'=>$j,'recorded_correction_result_id'=>$j?$results->get($j)?->id:null,'current_dependency_conference_required'=>true];}$panels['costs']=$this->page($p,$rows);
+3 -3
View File
@@ -30,9 +30,9 @@ final class SalesLedgerSource
if(!$ob||!$open)throw new \DomainException('Conferência financeira de abertura pendente; não presumir dívida aprovada.');
$origin=json_decode($ob->data,true)['origin']??[];
if(($origin['invoice_id']??null)!==$id||!hash_equals($origin['snapshot_sha256']??'',hash('sha256',$row->snapshot))||(int)$open->actor_id===(int)$ob->actor_id)throw new \DomainException('Abertura ou fotografia documental divergente.');
$calc=$snapshot['calculation'];$amount=$this->minor($calc['total_document_proposed']);
$calc=$snapshot['calculation'];$amount=$this->minor($calc['total_document_proposed']);$documentBase=$calc['base_exact'];if(($snapshot['calculation_policy']??null)===\App\Modules\Services\MultiLineInvoiceCalculation::POLICY){$values=\App\Modules\Services\MultiLineInvoiceCalculation::financialAmounts($snapshot);$documentBase=$values['base'];}
if($this->minor($ob->amount)!==$amount||$this->minor($open->amount)!==$amount)throw new \DomainException('Principal não coincide com o documento.');
return ['effect_type'=>'sales.invoice','origin_key'=>$this->hash(['scope'=>$scope,'invoice'=>$id,'series'=>$row->series_code,'number'=>$row->number]),'source_id'=>$id,'invoice_id'=>$id,'obligation_id'=>(int)$ob->id,'recognition_revision'=>$this->hash(['invoice_snapshot'=>$snapshot,'pdf_sha256'=>$row->pdf_sha256,'opening'=>(array)$open,'obligation'=>(array)$ob]),'principal_minor'=>$amount,'amount_minor'=>$amount,'base_minor'=>$this->minor($calc['base_exact']),'tax_minor'=>$this->minor($calc['vat_document_proposed']),'currency'=>'MZN','compatible'=>true,'history_imported'=>false,'due_on'=>json_decode($ob->data,true)['due_date']??null,'document_revision'=>hash('sha256',$row->snapshot),'document_snapshot'=>$snapshot,'opening_effect_id'=>$open->id];
return ['effect_type'=>'sales.invoice','origin_key'=>$this->hash(['scope'=>$scope,'invoice'=>$id,'series'=>$row->series_code,'number'=>$row->number]),'source_id'=>$id,'invoice_id'=>$id,'obligation_id'=>(int)$ob->id,'recognition_revision'=>$this->hash(['invoice_snapshot'=>$snapshot,'pdf_sha256'=>$row->pdf_sha256,'opening'=>(array)$open,'obligation'=>(array)$ob]),'principal_minor'=>$amount,'amount_minor'=>$amount,'base_minor'=>$this->minor($documentBase),'tax_minor'=>$this->minor($calc['vat_document_proposed']),'currency'=>'MZN','compatible'=>true,'history_imported'=>false,'due_on'=>json_decode($ob->data,true)['due_date']??null,'document_revision'=>hash('sha256',$row->snapshot),'document_snapshot'=>$snapshot,'opening_effect_id'=>$open->id];
}
public function source(User $u,string $kind,int $id):array
{
@@ -75,7 +75,7 @@ final class SalesLedgerSource
}
public function workspace(User $u,?int $invoice,int $page=1):array
{
$scope=$this->scope($u);$p=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($scope)->orderByDesc('id')->paginate(25,['id','series_code','number','issued_at'],'page',$page);
$scope=$this->scope($u);$p=\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderByDesc('id')->paginate(25,['id','series_code','number','issued_at'],'page',$page);
$data=['scope'=>$scope,'invoices'=>$p->items(),'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()],'invoice'=>null];if($invoice===null)return $data;
$data['invoice']=app(ServiceInvoicing::class)->show($invoice,$u->id);
$row=DB::table('si_invoices')->where($scope)->where('id',$invoice)->first()??abort(403);
@@ -9,4 +9,5 @@ final class PersonnelController
public function confirm(Request $r,Personnel $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,Personnel $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
public function export(Request $r,Personnel $f,int $id){abort_if($r->query()!==[],422);$out=$f->export($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-rh-pessoal-interno-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('Cache-Control','no-store, private');}
public function readable(Request $r,Personnel $f,int $id){abort_if($r->query()!==[],422);$out=$f->readable($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-rh-resumo-legivel-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('X-Source-SHA256',$out['source_sha256'])->header('X-SGF-Read-Format',$out['format'])->header('Cache-Control','no-store, private');}
}
+11 -4
View File
@@ -54,7 +54,7 @@ final class Personnel
private function plan(int $actor,array $s,array $d,array $b):array{
$last=$b['last'];$eff=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$eff['data']??null;
if($d['action']==='prepare'){$in=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];HumanResourcesProfiles::assertActor($prepared,$s,'prepare');abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador mudou.');foreach(['view','prepare']as$a)$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($d['kind']).'.'.$a,$s['unit_id']);$in=$last['data']['input'];abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Fontes mudaram desde a preparação. Prepare novamente.');}
if($d['action']==='prepare'){$in=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];HumanResourcesProfiles::assertActor($prepared,$s,'prepare');abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador mudou.');foreach(['view','prepare']as$a)$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($d['kind']).'.'.$a,$s['unit_id']);$in=$last['data']['input'];if($d['data']['decision']==='refused')return$this->refusal($s,$d,$b,$prepared,$identity);abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Fontes mudaram desde a preparação. Prepare novamente.');}
$op=$in['operation'];$kind=$d['kind'];$worker=$in['worker_key'];if($op==='establish')abort_if($eff,409,'Já revisto. Use correcção neste registo.');else abort_unless($old&&$old['status']!=='cancelled',409,'Operação exige origem efectiva não cancelada.');if($last)abort_unless($worker===$last['data']['input']['worker_key'],409,'Pessoa imutável neste registo.');if($op!=='cancel')abort_unless($b['dependencies']['worker']['status']==='active',409,'Cadastro suspenso.');
if(in_array($op,['cancel','take'],true)){foreach($old['input']as$k=>$v)if(!in_array($k,['operation','evidence','reference_on'],true))abort_unless($this->hash(['v'=>$in[$k]??null])===$this->hash(['v'=>$v]),409,'Esta decisão conserva a origem e os valores revistos.');}
$status=$op==='cancel'?'cancelled':'active';$result=[];
@@ -86,6 +86,10 @@ final class Personnel
$data=['input'=>$in,'status'=>$status,'dependencies'=>$b['dependencies'],'result'=>$result,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;if($state==='approved'){$csv=$this->csv($kind,$d['record_key'],$version,$data);$data['artifact_csv']=$csv;$data['artifact_sha256']=hash('sha256',$csv);}
$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$kind,'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
}
private function refusal(array $s,array $d,array $b,int $prepared,string $identity):array{
$last=$b['last'];$old=$b['effective']['data']??null;$data=$last['data'];unset($data['artifact_csv'],$data['artifact_sha256']);$currentBalance=null;if($d['kind']==='leave'){try{$currentBalance=$this->balance($s,$data['input']['entitlement_key']);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){if($e->getStatusCode()!==409)throw$e;}}$data['refusal_conference']=['current_balance'=>$currentBalance,'observed_dependencies'=>$b['dependencies'],'original_source_changed'=>$this->hash($last['data']['dependencies'])!==$this->hash($b['dependencies']),'proposal_applied'=>false];$version=$d['version']+1;$impact=['before'=>['version'=>$last['version'],'state'=>$last['state'],'effective'=>$old],'after'=>['version'=>$version,'state'=>'refused','proposal'=>$data,'effective'=>$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$d['kind'],'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id'],'action'=>'review','state'=>'refused','prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
}
public function period(int $actor,array $s,string $worker,string $period):array{
$this->scope($actor,'report');$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');$q=$this->personQuery($this->effective($s,'day'),$worker)->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'date'>=? AND data->'input'->>'date'<=?",[$from,$to])->orderByRaw("data->'input'->>'date'");$days=[];$known=0;$unknown=0;$unaccounted=0;$contracts=[];$entitlements=[];
foreach($q->cursor()as$r){abort_if(count($days)>=31,409,'Mais de um registo efectivo por dia exige conferência.');$row=$this->row($r);$i=$row['data']['input'];$leaves=$this->leaveForDay($s,$worker,$i['date']);$calc=PersonnelTime::day($i['date'],$i['planned'],$i['actual'],$i['leave_conferred']?$leaves:null);$days[]=['record_key'=>$r->record_key,'version'=>$r->version,'contract_key'=>$i['contract_key'],'calculation'=>$calc];if($calc['unaccounted_minutes']===null)$unknown++;else{$known++;$unaccounted+=$calc['unaccounted_minutes'];}$contracts[$i['contract_key']]=true;}
@@ -95,17 +99,20 @@ final class Personnel
return['period'=>$period,'worker'=>$this->worker($s,$worker),'days'=>$days,'contracts'=>$contractSources,'leave_balances'=>$entitlements,'payroll'=>$payroll,'known_days'=>count($days),'known_conferred_days'=>$known,'days_with_unknown_conference'=>$unknown,'other_calendar_days_unknown'=>(int)substr($to,-2)-count($days),'unaccounted_known_minutes'=>$unaccounted,'complete_month_proven'=>false,'basis'=>'explicit_days_only_not_legal_calendar','salary_effect'=>false];
}
private function csv(string $kind,string $key,int $version,array $data):string{$rows=[['SGF QA - RH INTERNO - NAO E DOCUMENTO LEGAL'],['Tipo',$kind,'Registo',$key,'Versao',$version],['Pessoa ficticia',$data['dependencies']['worker']['code']],['Estado',$data['status']],['Sem salario/pagamento/lancamento automaticos'],['Input',$this->json($data['input'])],['Resultado',$this->json($data['result'])],['Fontes',$this->json($data['dependencies'])]];return implode("\r\n",array_map(fn($r)=>implode(';',array_map(fn($v)=>'"'.str_replace('"','""',"'".(string)$v).'"',$r)),$rows))."\r\n";}
public function export(int $actor,int $id):array{$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];$r=$this->row($this->rows($s)->where(['id'=>$id,'state'=>'approved'])->first());abort_unless($r,403);$this->scope($actor,$r['kind']);$d=$r['data'];abort_unless(hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return['bytes'=>$d['artifact_csv'],'sha256'=>$d['artifact_sha256']];}
private function approvedExportRecord(int $actor,int $id):array{$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];$r=$this->row($this->rows($s)->where(['id'=>$id,'state'=>'approved'])->first());abort_unless($r,403);$this->scope($actor,$r['kind']);$d=$r['data'];abort_unless(hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return$r;}
public function export(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);return['bytes'=>$r['data']['artifact_csv'],'sha256'=>$r['data']['artifact_sha256']];}
public function readable(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);$bytes=PersonnelReadableCsv::render($r);return['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes),'source_sha256'=>$r['data']['artifact_sha256'],'format'=>PersonnelReadableCsv::VERSION];}
public function payrollContract(int $actor,string $key,string $worker,string $period,string $gross):array{$s=$this->scope($actor,'contract');$r=$this->source($s,$key,'contract',$worker);$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');abort_unless($this->covers($r,$from,$to)&&$r['data']['input']['gross']===$gross,409,'Contrato revisto não cobre o mês/bruto escolhidos.');return['record_key'=>$key,'version'=>$r['version'],'id'=>$r['id'],'latest_id'=>$this->latest($s,$key)['id'],'reference'=>$r['data']['input']['reference'],'gross'=>$gross,'valid_from'=>$r['data']['input']['valid_from'],'valid_to'=>$r['data']['input']['valid_to']];}
public function listing(int $actor,array $input):array{
$d=$this->exact($input,['kind'=>'required|in:contract,day,entitlement,leave,report','worker_key'=>'sometimes|uuid','record_key'=>'sometimes|uuid','period'=>'sometimes|date_format:Y-m','date'=>'sometimes|date_format:Y-m-d','state'=>'sometimes|in:prepared,approved,refused','search'=>'sometimes|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$s=$this->scope($actor,$d['kind']);
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$worker=isset($d['worker_key'])?$this->worker($s,strtolower($d['worker_key'])):null;$q=$this->rows($s)->where('kind',$d['kind']);if($worker)$q=$this->personQuery($q,$worker['record_key']);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403);}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('hp_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'hp_versions.'.$k);$n->whereColumn('next.version','>','hp_versions.version');});
if(isset($d['period']))$q->whereRaw("coalesce(data->'input'->>'period',substring(data->'input'->>'date' from 1 for 7))=?",[$d['period']]);if(isset($d['date']))$q->whereRaw("data->'input'->>'date'=?",[$d['date']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("(coalesce(data->'input'->>'reference','')||' '||coalesce(data->'dependencies'->'worker'->>'code','')) ILIKE ? ESCAPE '!'",[$needle]);}
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->all();$effective=$this->effective($s,$d['kind'])->whereIn('record_key',$keys)->get()->keyBy('record_key');$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$items[]=$a;}$page->setCollection(collect($items));$area=$this->area($d['kind']);return['policy'=>self::POLICY,'kind'=>$d['kind'],'worker'=>$worker,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.review',$s['unit_id'])],'synthetic_only'=>true,'salary_effect'=>false,'legal_framework_validated'=>false];});
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->all();$effective=$this->effective($s,$d['kind'])->whereIn('record_key',$keys)->get()->keyBy('record_key');$balances=[];if($d['kind']==='entitlement'&&$keys!==[]){$totals=$this->effective($s,'leave')->whereIn(DB::raw("data->'input'->>'entitlement_key'"),$keys)->selectRaw("data->'input'->>'entitlement_key' balance_key,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->groupByRaw("data->'input'->>'entitlement_key'")->get()->keyBy('balance_key');foreach($effective as$key=>$row){$e=$this->row($row);if($e['data']['status']==='cancelled')continue;$sum=$totals->get($key);$initial=$e['data']['input']['minutes'];$reserved=(int)($sum?->reserved??0);$taken=(int)($sum?->taken??0);$balances[$key]=['effective_entitlement_version'=>$e['version'],'entitlement_minutes'=>$initial,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$initial===null?null:$initial-$reserved-$taken,'basis'=>'current_effective_requests_at_read'];}}
$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$a['current_balance']=$balances[$r->record_key]??null;$items[]=$a;}$page->setCollection(collect($items));$area=$this->area($d['kind']);return['policy'=>self::POLICY,'kind'=>$d['kind'],'worker'=>$worker,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.review',$s['unit_id'])],'synthetic_only'=>true,'salary_effect'=>false,'legal_framework_validated'=>false];});
}
public function preview(int $actor,array $input,string $key):array{$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);$b=$this->basis($actor,$s,$d);$out=$this->plan($actor,$s,$d,$b);$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($c)),'impact'=>$out['impact']];});}
public function confirm(int $actor,string $token):array{
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('hp_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('hp_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('hp_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.remunerations.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('hp_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('hp_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('hp_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.'.$this->area($d['kind']).'.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason'],'area'=>$this->area($d['kind'])]);return$result+['replayed'=>false];});
}
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,'report');$old=DB::table('hp_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];}
@@ -0,0 +1,20 @@
<?php
namespace App\Modules\HumanResources;
/** A human-readable derivation of one immutable approved version, never a new decision. */
final class PersonnelReadableCsv
{
public const VERSION='SGF-HR-READABLE-CSV-v1';
private static function time(mixed $value):string{return$value===null?'Desconhecido':sprintf('%dh %02dmin (%d minutos)',intdiv((int)$value,60),(int)$value%60,(int)$value);}
public static function render(array $row):string
{
$d=$row['data'];$i=$d['input'];$r=$d['result'];$states=['active'=>'Activo','terminated'=>'Terminado','cancelled'=>'Cancelado','reserved'=>'Reservado','taken'=>'Gozado'];$titles=['contract'=>'Contrato interno QA','day'=>'Plano e assiduidade QA','entitlement'=>'Condição de férias QA','leave'=>'Pedido e gozo de férias QA','report'=>'Conferência do período QA'];
$lines=[['SGF — RECURSOS HUMANOS — RESUMO INTERNO QA'],['Documento',$titles[$row['kind']]],['Natureza','Vista legível derivada da versão conservada; não é documento legal nem nova decisão.'],['Colaborador fictício',$d['dependencies']['worker']['code']],['Registo',$row['record_key']],['Versão revista',$row['version']],['Estado nesta versão',$states[$d['status']]??$d['status']],['Preparação','QA'.$row['prepared_by']],['Autor desta revisão','QA'.$row['actor_id']],['Data da revisão (origem UTC)',$row['created_at']],['Fundamento',$row['reason']],['Referência da evidência',$i['evidence']],['Formato de leitura',self::VERSION],['SHA do CSV original conservado',$d['artifact_sha256']],[]];
if($row['kind']==='contract'){$lines=[...$lines,['CONDIÇÕES DO CONTRATO'],['Referência',$i['reference']],['Modalidade interna',['fixed'=>'Prazo explícito','open'=>'Sem termo declarado','unknown_end'=>'Fim desconhecido'][$i['mode']]],['Início',$i['valid_from']],['Fim',$i['valid_to']??'Não confirmado'],['Bruto mensal declarado',$i['gross'].' MZN'],['Registos dependentes observados nesta decisão',$r['dependent_records']],['Validação jurídica','Não demonstrada']];}
if($row['kind']==='day'){$lines=[...$lines,['ASSIDUIDADE DO DIA'],['Dia local',$i['date']],['Zona',$r['zone']],['Referência da declaração',$i['reference_on']],['Tempo previsto',self::time($r['planned_minutes'])],['Tempo declarado',self::time($r['declared_minutes'])],['Férias conferidas',self::time($r['leave_minutes'])],['Tempo por conferir',self::time($r['unaccounted_minutes'])],['Fora do plano (sem autorização de horas extra)',self::time($r['outside_plan_minutes'])]];foreach(['planned'=>'Intervalos previstos','actual'=>'Intervalos declarados']as$key=>$title){$lines[]=[$title];if($i[$key]===null)$lines[]=['Informação','Desconhecida'];elseif($i[$key]===[])$lines[]=['Informação','Conjunto explicitamente vazio'];else foreach($i[$key]as$range)$lines[]=['Desde / até (hora local)',sprintf('%02d:%02d',intdiv($range['from'],60),$range['from']%60),sprintf('%02d:%02d',intdiv($range['to'],60),$range['to']%60)];}}
if(in_array($row['kind'],['entitlement','leave'],true)){$b=$r['balance_after']??$r;$lines[]=['FÉRIAS — VALORES CONSERVADOS NESTA VERSÃO'];if($row['kind']==='entitlement')$lines[]=['Vigência',$i['valid_from'].' a '.$i['valid_to']];else{$lines[]=['Dia',$i['date']];$lines[]=['Pedido',self::time($r['minutes'])];}$lines=[...$lines,['Orçamento explícito',self::time($b['entitlement_minutes']??null)],['Reservado',self::time($b['reserved_minutes'])],['Gozado',self::time($b['taken_minutes'])],['Disponível',self::time($b['available_minutes'])],['Limite','Orçamento fictício; não comprova direito legal anual. Não é saldo actual se decisões posteriores existirem.']];}
if($row['kind']==='report'){$lines=[...$lines,['CONFERÊNCIA DO PERÍODO'],['Período',$r['period']],['Dias com registos',$r['known_days']],['Dias conferidos',$r['known_conferred_days']],['Dias com conferência desconhecida',$r['days_with_unknown_conference']],['Outros dias sem cobertura comprovada',$r['other_calendar_days_unknown']],['Mês completo','Não demonstrado; dias sem registos não são faltas presumidas.'],[],['Dia local','Versão do dia','Previsto','Declarado','Férias conferidas','Por conferir']];foreach($r['days']as$day){$c=$day['calculation'];$lines[]=[$c['date'],$day['version'],self::time($c['planned_minutes']),self::time($c['declared_minutes']),self::time($c['leave_minutes']),self::time($c['unaccounted_minutes'])];}$lines[]=[];$lines[]=['SALDOS DE FÉRIAS NESTA CONFERÊNCIA','Versão da condição','Orçamento','Reservado','Gozado','Disponível'];foreach($r['leave_balances']as$b)$lines[]=['Condição QA',$b['entitlement_version'],self::time($b['entitlement_minutes']),self::time($b['reserved_minutes']),self::time($b['taken_minutes']),self::time($b['available_minutes'])];$lines[]=[];$lines[]=['PROCESSAMENTOS OBSERVADOS','Versão','Líquido interno (centavos)','Ligação contratual comprovada'];foreach($r['payroll']as$p)$lines[]=['Processamento QA',$p['version'],$p['net_cents']??'Indeterminado',$p['contract_link_proven']?'Sim, escolhida na origem':'Não; sem atribuição retroactiva'];}
$lines=[...$lines,[],['Limites','Sem salário, dívida, pagamento, lançamento ou submissão automáticos. Sem conformidade legal presumida.'],['Fonte','Apenas esta versão revista. Não consulta nem recalcula o estado actual.']];
// Apostrophe protects all free text, including formula prefixes after whitespace. BOM supports local Excel.
return "\xEF\xBB\xBF".implode("\r\n",array_map(fn($line)=>implode(';',array_map(fn($cell)=>'"'.str_replace('"','""',"'".(string)$cell).'"',$line)),$lines))."\r\n";
}
}
@@ -51,6 +51,8 @@ final class IdentityWorkflow
'analysis_qa_enabled'=>app()->environment('budget-testing')&&config('analysis_qa.enabled')===true,
'bank_qa_enabled'=>app()->environment('budget-testing')&&config('bank_qa.enabled')===true,
'fiscal_rules_qa_enabled'=>app()->environment('budget-testing')&&config('fiscal_rules_qa.enabled')===true,
'multiline_invoices_enabled'=>app()->environment('budget-testing')&&config('multiline_invoices.enabled')===true,
'fixed_assets_enabled'=>app()->environment('budget-testing')&&config('fixed_assets.enabled')===true,
'internal_statements_enabled'=>app()->environment('budget-testing')&&config('internal_statements.enabled')===true,
'hr_personnel_enabled'=>app()->environment('budget-testing')&&config('hr_personnel.enabled')===true,
'remunerations_enabled'=>app()->environment('budget-testing')&&config('remunerations.enabled')===true,
+5 -4
View File
@@ -7,22 +7,23 @@ final class FiscalCoverage
public static function describe(): array
{
return [
'version'=>'SGF-FISCAL-COVERAGE-v2',
'version'=>'SGF-FISCAL-COVERAGE-v4',
'automatic_knowledge_import'=>false,
'legal_validation'=>false,
'notice'=>'Conhecimento em documentos ou conversas não passa automaticamente para a aplicação. Cada regra exige fonte, versão, âmbito, provas e revisão antes de utilização.',
'taxes'=>[
['id'=>'iva','label'=>'IVA','state'=>'partial','available'=>'Cálculo delimitado de serviço, MZN, uma linha e imposto acrescido; perfis versionados, revisão distinta e decisão temporal. Documentos QA e diário integrado; livro/mapa interno da origem seleccionada revistos distintamente. Núcleo comum com versões/vigência e cálculo interno QA de1–25linhas, preço acrescido/incluído, exactos e diferenças preservados.','pending'=>'Emissão documental por múltiplas linhas/tratamentos, livros gerais de apuramento/dedução, regularizações e modelos oficiais.','legal_use'=>false],
['id'=>'irps','label'=>'IRPS','state'=>'pending','available'=>null,'pending'=>'Motor operacional de rendimentos/retenções, remunerações, apuramento e modelos ligados às operações do SGF.','legal_use'=>false],
['id'=>'irps','label'=>'IRPS','state'=>'partial','available'=>'RH autónomo com cadastro, contratos/tempos/férias e processamento interno QA revisto; retenção artificial explicada e mapa conservado.','pending'=>'Cálculo legal de IRPS por regime/rendimento, retenções/apuramento e modelos oficiais; o ensaio interno não valida o imposto.','legal_use'=>false],
['id'=>'irpc','label'=>'IRPC','state'=>'pending','available'=>null,'pending'=>'Ponte do resultado contabilístico para ajustamentos/base/colecta, retenções e modelos no SGF.','legal_use'=>false],
['id'=>'ispc','label'=>'ISPC','state'=>'pending','available'=>null,'pending'=>'Enquadramento, cálculo, obrigações e modelos operacionais no SGF.','legal_use'=>false],
['id'=>'ice','label'=>'ICE','state'=>'pending','available'=>null,'pending'=>'Classificação dos bens, parâmetros com vigência, liquidação e obrigações operacionais no SGF.','legal_use'=>false],
['id'=>'inss','label'=>'INSS','state'=>'pending','available'=>null,'pending'=>'Cadastro/processamento de remunerações, contribuições e modelos no SGF.','legal_use'=>false],
['id'=>'inss','label'=>'INSS','state'=>'partial','available'=>'Cadastro e processamento interno QA em RH, com desconto do trabalhador e encargo patronal artificiais separados e mapa revisto.','pending'=>'Regimes/bases/contribuições legais INSS, reconciliação e modelos oficiais; percentagens do exercício não são parâmetros legais activados.','legal_use'=>false],
['id'=>'other','label'=>'Outros impostos e obrigações','state'=>'pending','available'=>null,'pending'=>'Identificação dos tributos aplicáveis, calendário legal, multas/juros e modelos; sem taxas ou prazos presumidos.','legal_use'=>false],
],
'management'=>[
['id'=>'human_resources','label'=>'Recursos Humanos','state'=>'partial','available'=>'Módulo autónomo e perfis próprios: cadastro, contratos internos, plano/assiduidade, condições/pedidos/gozo de férias, conferência de período e remunerações internas QA; histórico e resumos legíveis.','pending'=>'Aplicabilidade laboral real, calendário/turnos gerais, benefícios, cálculo legal IRPS/INSS e efeitos salariais financeiros/contabilísticos.'],
['id'=>'finance','label'=>'Gestão financeira','state'=>'partial','available'=>'Percursos QA de orçamento de Compras, obrigações/recebíveis, afectações/reversões e Caixa com fecho; conferência individual de extracto artificial contra duas linhas existentes.','pending'=>'Operação geral real, bancos e reconciliação bancária geral.'],
['id'=>'accounting','label'=>'Contabilidade','state'=>'partial','available'=>'Diário, plano, razão, balancete e conferência/fecho QA com origens distintas.','pending'=>'Demonstrações financeiras legais, encerramento anual real e integração dos apuramentos fiscais.'],
['id'=>'accounting','label'=>'Contabilidade','state'=>'partial','available'=>'Diário, plano, razão, balancete e conferência/fecho QA; demonstrações internas parciais revistas/conservadas, com contas não classificadas explícitas. Cadastro/custódia, plano mensal, quota e diário de activos QA integrados, com correcção e abate no recorte sintético.','pending'=>'Operação geral de activos e tratamento fiscal de depreciações, demonstrações financeiras legais, encerramento anual real e integração dos apuramentos fiscais.'],
['id'=>'analysis','label'=>'Análise financeira','state'=>'partial','available'=>'Custos directos/industriais, orçamento versus realizado, diário/saldos e dois critérios/indicadores QA revistos com fontes explícitas e relatórios.','pending'=>'Rácios de liquidez/rentabilidade/endividamento e tendências conciliadas, com plano de contas e critérios aprovados.'],
],
'rule_lifecycle'=>['Fonte identificada e versão preservada','Aplicabilidade e vigência explícitas','Cálculo exacto com pressupostos/pendências','Provas e revisão por pessoa distinta','Activação específica e histórico sem reescrever operações anteriores'],
+9
View File
@@ -94,6 +94,15 @@ final class FiscalRules
$gate=$this->evaluate($s,['base'=>'0','currency'=>$in['currency'],'date'=>$in['date']],$selected);if($gate['state']!=='determined_qa'){unset($gate['base']);return $gate+['policy'=>FiscalDocumentMath::POLICY,'price_mode'=>$in['price_mode'],'base_document'=>null,'lines'=>[]];}
return FiscalDocumentMath::calculate($in['lines'],$in['price_mode'],$selected['definition']['parameters']['rate'])+['currency'=>$in['currency'],'rule'=>$gate['rule']];
}
/** Explicit read-only QA bridge; does not change the module required by rule administration. */
public function invoiceDocument(int $actor,array $scope,int $event,array $input,?int $boundCalculation=null):array
{
abort_unless(config('multiline_invoices.enabled')===true&&config('fiscal_rules_qa.enabled')===true,404);CompositionRuntime::assert();$actual=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$actual===$scope&&ModuleRecords::active()==='commercial',403);if(!$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'fiscal_rules.view',$scope['unit_id'])){
// Invoice readers cannot enumerate/administer the rule catalogue. Only revalidate
// a calculation already captured for this exact draft/input by a fiscal preparer.
$this->permissions->require($actor,$scope['tenant_id'],$scope['company_id'],'billing_drafts.view',$scope['unit_id']);$row=$boundCalculation?DB::table('mi_calculations')->where($scope)->where('id',$boundCalculation)->first():null;abort_unless($row,403,'Regra exige captura documental previamente autorizada.');$frozen=json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR);$expected=$frozen['common']['input'];unset($expected['rule_event_id']);abort_unless((int)$frozen['common']['rule']['id']===$event&&$this->hash($expected)===$this->hash($input),409,'Entrada/data da captura fiscal mudou.');
}$input['rule_event_id']=$event;$in=$this->documentInput($input);$row=$this->events($scope)->where('id',$event)->first();abort_unless($row,403,'Regra fora do contexto.');$selected=$this->row($row);$this->fixture();return ['rule'=>$selected,'result'=>$this->evaluateDocument($scope,$in,$selected),'input'=>$in,'rule_stream'=>['count'=>$this->events($scope)->count(),'last_id'=>$this->events($scope)->max('id')]];
}
public function documentReport(int $actor,int $id):array
{
$detail=$this->detail($actor,$id,'calculation');$r=$detail['result'];abort_unless(isset($r['price_mode'],$r['lines']),422,'Este registo não é cálculo por linhas.');$fp=fopen('php://temp','w+');$rule=$r['rule']??[];$rows=[['MAPA INTERNO POR LINHAS QA — SEM VALIDADE FISCAL'],['Politica',$r['policy']],['Modo',$r['price_mode']],['Data',$detail['input']['date']],['Moeda',$detail['input']['currency']],['Regra escolhida',(string)($detail['input']['rule_event_id']??'INDETERMINADA')],['Versao regra',(string)($rule['version']??'INDETERMINADA')],['Fonte',$rule['source_reference']??'INDETERMINADA'],['SHA fonte',$rule['source_sha256']??'INDETERMINADO'],['Estado historico',$r['state']],['Estado actual',$detail['current_evaluation']['state']],['Linha','Referencia','Base exacta fraccao','Imposto exacto fraccao','Base documental','Imposto documental','Total','Diferenca base fraccao','Diferenca imposto fraccao']];foreach($r['lines']as$line){$ref="'".$line['reference'];$rows[]=[(string)$line['line'],$ref,$line['base_exact_fraction'],$line['tax_exact_fraction'],$line['base_document']??'INDETERMINADO',$line['tax_document'],$line['total_document']??'INDETERMINADO',$line['base_difference_fraction']??'INDETERMINADO',$line['tax_difference_fraction']];}$rows[]=['Totais documentais','','','',$r['base_document']??'INDETERMINADO',$r['tax_document']??'INDETERMINADO',$r['total']??'INDETERMINADO'];foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);$s=$this->scope($actor);Audit::record('fiscal_rule.document_report',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>0,'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];
@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Services\Http;
use App\Modules\Services\{MultiLineInvoiceCalculation,MultiLineDrafts,ServiceWorkflow,ServiceInvoicing,DocumentPreparation,FiscalRules};
use App\Shared\Context\ModuleRecords;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{DB,Validator};
final class MultiLineInvoiceController
{
private function response(\Closure $fn){try{$value=$fn();return $value instanceof \Symfony\Component\HttpFoundation\Response?$value:response()->json($value)->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Documento ocupado. Recupere a mesma operação, sem gerar outra chave.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
private function exactQuery(Request $r,array $rules):array{abort_if(array_diff(array_keys($r->query()),array_keys($rules)),422);return Validator::make($r->query(),$rules)->validate();}
public function catalogue(Request $r,MultiLineInvoiceCalculation $mi){return$this->response(function()use($r,$mi){$s=$mi->scope($r->user()->id,'unit.view');$d=$this->exactQuery($r,['kind'=>'required|in:clients,services,drafts,rules,series,temporals,calculations','draft_id'=>'sometimes|integer|min:1','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$kind=$d['kind'];$q=null;
if(in_array($kind,['clients','services','drafts'],true)){$area=$kind==='drafts'?'billing_drafts':$kind;app(ServiceWorkflow::class)->scope($area,$r->user()->id,'view');$q=ModuleRecords::query('a_'.$area)->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('unit_id',$kind==='drafts'?$s['unit_id']:null);if($kind==='drafts')$q->whereIn('id',DB::table('mi_drafts')->where($s)->select('draft_id'));if(!empty($d['search'])){$v='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("name ILIKE ? ESCAPE '!'",[$v]);}$columns=['id','name','version'];}
elseif($kind==='rules'){$s=$mi->scope($r->user()->id,'fiscal_rules.view');$q=DB::table('fr_events as f')->where(['f.tenant_id'=>$s['tenant_id'],'f.company_id'=>$s['company_id'],'f.unit_id'=>$s['unit_id'],'f.state'=>'reviewed','f.definition->family'=>'qa','f.definition->source_status'=>'qa_checked'])->whereNotExists(function($n){$n->selectRaw('1')->from('fr_events as n')->whereColumn('n.tenant_id','f.tenant_id')->whereColumn('n.company_id','f.company_id')->whereColumn('n.unit_id','f.unit_id')->whereColumn('n.rule_key','f.rule_key')->whereColumn('n.rule_version','f.rule_version')->whereColumn('n.revision','>','f.revision');});$columns=['f.id','f.rule_key','f.rule_version','f.definition'];}
elseif($kind==='series'||$kind==='temporals'){$mi->scope($r->user()->id,'service_invoices.view');$q=DB::table('si_proposals')->where($s)->where('kind',$kind==='series'?'series':'temporal');if($kind==='series')$q->where('identifier','like','TEST-MULTI-%');else{abort_unless(isset($d['draft_id']),422);app(ServiceWorkflow::class)->show('billing_drafts',(int)$d['draft_id'],$r->user()->id);$q->where('data->draft_id',(int)$d['draft_id'])->where('data->calculation_policy',MultiLineInvoiceCalculation::POLICY);}$columns=['id','identifier','version','kind','actor_id','data','created_at'];}
else{abort_unless(isset($d['draft_id']),422);app(ServiceWorkflow::class)->show('billing_drafts',(int)$d['draft_id'],$r->user()->id);$q=DB::table('mi_calculations')->where($s)->where('draft_id',(int)$d['draft_id']);$columns=['id','actor_id','snapshot','created_at'];}
if(!empty($d['search'])&&in_array($kind,['rules','series','temporals','calculations'],true)){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$column=match($kind){'rules'=>"f.definition->>'label'",'calculations'=>'id::text',default=>'identifier'};$q->whereRaw($column." ILIKE ? ESCAPE '!'",[$needle]);}
$page=$q->orderByDesc($kind==='rules'?'f.id':'id')->paginate((int)($d['per_page']??25),$columns,'page',(int)($d['page']??1));$decisions=in_array($kind,['series','temporals'],true)?DB::table('si_decisions')->whereIn('proposal_id',$page->pluck('id')->all())->get(['proposal_id','actor_id','decision','reason'])->keyBy('proposal_id'):collect();$items=[];foreach($page as$row){$v=(array)$row;if($kind==='rules'){$definition=json_decode($v['definition'],true);unset($v['definition']);$v+=['name'=>$definition['label'],'valid_from'=>$definition['valid_from'],'valid_until'=>$definition['valid_until'],'rate'=>$definition['parameters']['rate']];}elseif(in_array($kind,['series','temporals'],true)){$v['data']=json_decode($v['data'],true);$v['decision']=$decisions->get($v['id']);}elseif($kind==='calculations'){$snapshot=json_decode($v['snapshot'],true,64);unset($v['snapshot']);$v+=['name'=>'Cálculo #'.$v['id'],'result'=>$snapshot['common']['result'],'rule_event_id'=>$snapshot['common']['rule']['id'],'draft_version'=>$snapshot['document']['draft_version'],'preparation_version'=>$snapshot['document']['preparation_version'],'date'=>$snapshot['expected_date']];}$items[]=$v;}$page->setCollection(collect($items));return ['kind'=>$kind,'records'=>$page->toArray()];});}
public function editor(Request $r,int $id,MultiLineInvoiceCalculation $mi){abort_if($r->query()!==[],422);return$this->response(function()use($r,$id,$mi){$s=$mi->scope($r->user()->id);$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$id)->first();abort_unless($binding,403);$w=app(ServiceWorkflow::class);$billing=$w->show('billing_drafts',$id,$r->user()->id);$source=$w->show('service_drafts',(int)$binding->source_id,$r->user()->id);$p=app(\App\Modules\Identity\Services\Permissions::class);$prep=$p->has($r->user()->id,$s['tenant_id'],$s['company_id'],'draft_documents.view',$s['unit_id'])?app(DocumentPreparation::class)->show('billing_drafts',$id,$r->user()->id):null;$parties=[];foreach(['issuer'=>$s['company_id'],'client'=>$billing['client_id']]as$kind=>$target){if($p->has($r->user()->id,$s['tenant_id'],$s['company_id'],$kind==='issuer'?'issuer_documents.view':'client_documents.view'))$parties[$kind]=app(DocumentPreparation::class)->show($kind,(int)$target,$r->user()->id);}$invoice=ModuleRecords::query('si_invoices')->where($s)->where('draft_id',$id)->first(['id','series_code','number']);if($invoice&&$p->has($r->user()->id,$s['tenant_id'],$s['company_id'],'service_invoices.view',$s['unit_id']))$invoice->totals=app(ServiceInvoicing::class)->show((int)$invoice->id,$r->user()->id)['snapshot']['calculation']['common_document_result']??null;return['billing'=>$billing,'source'=>$source,'preparation'=>$prep,'parties'=>$parties,'invoice'=>$invoice,'policy'=>MultiLineInvoiceCalculation::POLICY,'server_date'=>now(ServiceInvoicing::ZONE)->format('Y-m-d')];});}
public function draftPreview(Request $r,MultiLineDrafts $d){abort_if(strlen(json_encode($r->all()))>65536,422);return$this->response(fn()=>$d->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function draftConfirm(Request $r,MultiLineDrafts $d){abort_if(array_diff(array_keys($r->all()),['preview']),422);$r->validate(['preview'=>'required|string|max:262144']);return$this->response(fn()=>$d->confirm($r->user()->id,$r->input('preview')));}
public function calculationPreview(Request $r,MultiLineInvoiceCalculation $c){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$c->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function calculationConfirm(Request $r,MultiLineInvoiceCalculation $c){abort_if(array_diff(array_keys($r->all()),['preview']),422);$r->validate(['preview'=>'required|string|max:262144']);return$this->response(fn()=>$c->confirm($r->user()->id,$r->input('preview')));}
public function receipt(Request $r,string $key,MultiLineInvoiceCalculation $c){abort_if($r->query()!==[],422);return$this->response(fn()=>$c->receipt($r->user()->id,strtolower($key)));}
private function command(Request $r,\Closure $work){return$this->response(fn()=>DB::transaction(function()use($r,$work){DB::statement("SET LOCAL lock_timeout='5s'");app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);return$work();}));}
private function administrative(Request $r,string $domain,string $right,?int $target,\Closure $work){return$this->command($r,function()use($r,$domain,$right,$target,$work){$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);app(\App\Modules\Identity\Services\Permissions::class)->require($r->user()->id,$s['tenant_id'],$s['company_id'],$right);$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/D',$key),422);$input=['domain'=>$domain,'target'=>$target,'data'=>$r->all()];$hash=hash('sha256',\Tempo\Finance\Payables\Payables::canonical($input));DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);app(\App\Modules\Identity\Services\Permissions::class)->require($r->user()->id,$s['tenant_id'],$s['company_id'],$right);$q=$s+['actor_id'=>$r->user()->id,'command_key'=>$key];$old=DB::table('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave usada noutra operação.');return json_decode($old->result,true)+['replayed'=>true];}$out=$work();$out['type']=$domain;DB::table('mi_commands')->insert($q+['request_hash'=>$hash,'input'=>\Tempo\Finance\Payables\Payables::canonical($input),'result'=>\Tempo\Finance\Payables\Payables::canonical($out),'created_at'=>now()->utc()]);return$out+['replayed'=>false];});}
public function temporal(Request $r){abort_if(($r->input('kind')!=='temporal')||$r->input('data.calculation_policy')!==MultiLineInvoiceCalculation::POLICY,422);return$this->administrative($r,'temporal','service_fiscal.propose',null,fn()=>app(ServiceInvoicing::class)->propose($r->user()->id,$r->all()));}
public function temporalReview(Request $r,int $id){return$this->administrative($r,'temporal_review','service_fiscal.approve',$id,function()use($r,$id){$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);abort_unless(DB::table('si_proposals')->where($s)->where('id',$id)->where('data->calculation_policy',MultiLineInvoiceCalculation::POLICY)->exists(),403);return app(ServiceInvoicing::class)->decide($id,$r->user()->id,$r->all());});}
public function readiness(Request $r,int $id){abort_if(array_diff(array_keys($r->all()),['series_id','rule_id']),422);$d=$r->validate(['series_id'=>'required|integer|min:1','rule_id'=>'required|integer|min:1']);return$this->command($r,fn()=>app(ServiceInvoicing::class)->review($id,(int)$d['series_id'],(int)$d['rule_id'],$r->user()->id));}
public function issue(Request $r,int $id){return$this->command($r,function()use($r,$id){$s=app(MultiLineInvoiceCalculation::class)->scope($r->user()->id);abort_unless(DB::table('mi_drafts')->where($s)->where('draft_id',$id)->exists()&&DB::table('si_proposals')->where($s)->where('id',$r->input('rule_id'))->where('data->calculation_policy',MultiLineInvoiceCalculation::POLICY)->exists(),403);return app(ServiceInvoicing::class)->issue($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'));});}
public function issueReceipt(Request $r,string $key,MultiLineInvoiceCalculation $c){abort_if($r->query()!==[],422);return$this->response(function()use($r,$key,$c){$s=$c->scope($r->user()->id,'service_invoices.view');abort_unless(preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/D',$key),422);$row=DB::table('si_commands')->where($s+['actor_id'=>$r->user()->id,'command_key'=>$key])->first();abort_unless($row,404);$result=json_decode($row->result,true);$invoice=app(ServiceInvoicing::class)->show((int)$result['id'],$r->user()->id);abort_unless(($invoice['snapshot']['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,404);return ['result'=>$result,'historical_receipt_only'=>true,'operation_executed'=>false];});}
}
+1
View File
@@ -7,6 +7,7 @@ final class InvoicePdf
public const VERSION='sgf-test-invoice-text-v2';
public static function lines(array $s): array
{
if(($s['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY)return MultiLineInvoicePdf::lines($s);
$lines=[['title','Factura de serviço - demonstração isolada'],['text',$s['series_code'].' / '.$s['number']],['text','Emissão: '.$s['issued_local'].' ('.$s['timezone'].')'],['section','Emitente']];
foreach(['issuer'=>'Emitente','client'=>'Cliente'] as $key=>$title) {
if($key==='client') $lines[]=['section',$title];
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Services;
use Illuminate\Support\Facades\{DB,Crypt,Validator};
use Tempo\Finance\Payables\Payables;
/** Atomic administrative composition of the existing draft/preparation writers. No invoice writer. */
final class MultiLineDrafts
{
private function hash(array $a):string{return hash('sha256',Payables::canonical($a));}
private function input(array $v):array{
$rules=['draft_id'=>'present|nullable|integer|min:1','version'=>'required|integer|min:0','source_version'=>'required|integer|min:0','preparation_version'=>'required|integer|min:0','name'=>'required|string|min:3|max:255','client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:undefined,added,included','service_date'=>'present|nullable|date_format:Y-m-d','period_start'=>'present|nullable|date_format:Y-m-d','period_end'=>'present|nullable|date_format:Y-m-d|after_or_equal:period_start','proposed_due_date'=>'present|nullable|date_format:Y-m-d','payment_terms'=>'present|nullable|string|max:1000','lines'=>'required|array|min:1|max:25','lines.*'=>'array:service_id,description,quantity,unit_price,unit','lines.*.service_id'=>'required|integer|min:1','lines.*.description'=>'nullable|string|max:2000','lines.*.quantity'=>'present|nullable|string|max:30','lines.*.unit_price'=>'present|nullable|string|max:30','lines.*.unit'=>'present|nullable|string|max:80','reason'=>'required|string|min:10|max:2000'];abort_if(array_diff(array_keys($v),array_filter(array_keys($rules),fn($k)=>!str_contains($k,'*'))),422);$d=Validator::make($v,$rules)->validate();foreach(['draft_id','version','source_version','preparation_version','client_id']as$k)if($d[$k]!==null)$d[$k]=(int)$d[$k];abort_unless(array_is_list($d['lines']),422);foreach($d['lines']as$i=>$row){$d['lines'][$i]['service_id']=(int)$row['service_id'];foreach(['quantity','unit_price']as$f)$d['lines'][$i][$f]=DraftValues::decimal($row[$f],'lines.'.$i.'.'.$f,$f==='quantity');}foreach([$d['name'],$d['payment_terms'],$d['reason'],...array_column($d['lines'],'description')]as$value)if(is_string($value))abort_if((bool)preg_match('/(?:password|palavra.passe|token|secret|cvv|cvc|pin)\\s*[:=]/iu',$value),422,'Não introduza credenciais ou segredos.');return$d;
}
private function scope(int $actor,array $d):array{
$s=app(MultiLineInvoiceCalculation::class)->scope($actor,$d['draft_id']===null?'billing_drafts.create':'billing_drafts.edit');$m=DB::table('mi_draft_meta')->where('policy',MultiLineInvoiceCalculation::POLICY)->first();abort_unless($m&&$m->composition===\App\Shared\Installation\CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('multiline_invoices.draft_schema_sha256')),503,'Composição de rascunhos não conferida.');return$s;
}
private function basis(int $actor,array $s,array $d):array{
$w=app(ServiceWorkflow::class);$w->scope('clients',$actor,'view');$w->scope('services',$actor,'view');$client=\App\Shared\Context\ModuleRecords::query('a_clients')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['client_id'])->first();abort_unless($client,403);$ids=array_unique(array_column($d['lines'],'service_id'));$services=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->whereIn('id',$ids)->orderBy('id')->get();abort_unless($services->count()===count($ids),403,'Serviço fora do módulo/contexto.');$before=null;$binding=null;
if($d['draft_id']!==null){$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$d['draft_id'])->first();abort_unless($binding,403,'Este editor altera apenas os novos rascunhos multilinha.');$bill=$w->show('billing_drafts',$d['draft_id'],$actor);$source=$w->show('service_drafts',(int)$binding->source_id,$actor);$prepared=app(DocumentPreparation::class)->show('billing_drafts',$d['draft_id'],$actor);abort_unless($bill['version']===$d['version']&&$source['version']===$d['source_version']&&$prepared['version']===$d['preparation_version'],409,'Origem/rascunho/preparação alterados.');abort_if(DB::table('si_invoices')->where('draft_id',$d['draft_id'])->orWhere('source_id',$binding->source_id)->exists(),409,'Documento emitido preservado; não editar a origem por este percurso.');$before=['source'=>$source,'billing'=>$bill,'preparation'=>$prepared];}else abort_unless($d['version']===0&&$d['source_version']===0&&$d['preparation_version']===0,409);
return['client'=>(array)$client,'services'=>$services->map(fn($r)=>(array)$r)->all(),'binding'=>$binding?(array)$binding:null,'before'=>$before];
}
private function write(int $actor,array $s,array $d,array $b,string $key):array{
$w=app(ServiceWorkflow::class);$lines=array_map(function($l){unset($l['unit']);return$l;},$d['lines']);$sourceInput=['name'=>$d['name'],'client_id'=>$d['client_id'],'currency'=>$d['currency'],'lines'=>$lines];if($b['binding'])$sourceInput['version']=$d['source_version'];$source=$w->save('service_drafts',$b['binding']['source_id']??null,$actor,$sourceInput,$key.'-source');$billInput=$b['binding']?['version'=>$d['version'],'reload_source'=>true,'name'=>$d['name']]:['source_id'=>$source['id'],'name'=>$d['name']];$bill=$w->save('billing_drafts',$d['draft_id'],$actor,$billInput,$key.'-billing');$currentPrep=app(DocumentPreparation::class)->show('billing_drafts',$bill['id'],$actor);$prepared=app(DocumentPreparation::class)->save('billing_drafts',$bill['id'],$actor,['version'=>$currentPrep['version'],'draft_version'=>$bill['version'],'currency'=>$d['currency'],'service_date'=>$d['service_date'],'period_start'=>$d['period_start'],'period_end'=>$d['period_end'],'proposed_due_date'=>$d['proposed_due_date'],'payment_terms'=>$d['payment_terms'],'price_mode'=>$d['price_mode'],'units'=>array_column($d['lines'],'unit'),'payment_instruction_ids'=>[]]);
if(!$b['binding'])DB::table('mi_drafts')->insert($s+['draft_id'=>$bill['id'],'source_id'=>$source['id'],'actor_id'=>$actor,'created_at'=>now()->utc()]);return ['type'=>'draft','id'=>$bill['id'],'source_id'=>$source['id'],'version'=>$bill['version'],'source_version'=>$source['version'],'preparation_version'=>$prepared['version'],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0];
}
private function impact(array $d,array $b):array{$lines=$d['lines'];$map=collect($b['services'])->keyBy('id');foreach($lines as&$l)$l['service_name']=$map[$l['service_id']]['name'];unset($l);return['before'=>$b['before']?['source_version'=>$b['before']['source']['version'],'version'=>$b['before']['billing']['version'],'preparation_version'=>$b['before']['preparation']['version']]:null,'after'=>['client'=>$b['client']['name'],'name'=>$d['name'],'currency'=>$d['currency'],'price_mode'=>$d['price_mode'],'lines'=>$lines,'arithmetic_amount'=>DraftValues::base(['lines'=>$lines]),'period_start'=>$d['period_start'],'period_end'=>$d['period_end']],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0];}
public function preview(int $actor,array $v,string $key):array{
$d=$this->input($v);abort_unless(preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$d);return DB::transaction(function()use($actor,$s,$d,$key){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$b=$this->basis($actor,$s,$d);DB::beginTransaction();try{$this->write($actor,$s,$d,$b,$key);}finally{DB::rollBack();}$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'basis'=>$this->hash($b),'observed'=>time()];return ['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>$this->impact($d,$b)];});
}
public function confirm(int $actor,string $token):array{
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->input($c['input']??[]);$s=$this->scope($actor,$d);return DB::transaction(function()use($actor,$s,$c,$d){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();$this->scope($actor,$d);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409);$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$request=['domain'=>'draft','input'=>$d];$old=DB::table('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($request)),409);return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem ou versão alteradas; confira novamente.');$result=$this->write($actor,$s,$d,$b,$c['key']);DB::table('mi_commands')->insert($q+['request_hash'=>$this->hash($request),'input'=>Payables::canonical($request),'result'=>Payables::canonical($result),'created_at'=>now()->utc()]);\App\Modules\Identity\Services\Audit::record('service_invoice.multiline_draft_prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$result['id'],'version'=>$result['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{DB,Crypt,Validator};
use Tempo\Finance\Payables\Payables;
/** New typed QA capture. Existing one-line calculation and invoice writer retain their contracts. */
final class MultiLineInvoiceCalculation
{
public const POLICY='SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
private function exact(array $v,array $r):array{abort_if(array_diff(array_keys($v),array_keys($r)),422);return Validator::make($v,$r)->validate();}
public function scope(int $actor,string $right='billing_drafts.view'):array{
abort_unless(config('multiline_invoices.enabled')===true,404);CompositionRuntime::assert();ServiceInvoicing::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='commercial',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,str_starts_with($right,'service_fiscal.')&&$right!=='service_fiscal.calculate'?null:$s['unit_id']);if(str_starts_with($right,'service_fiscal.'))$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.view');$m=DB::table('mi_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('multiline_invoices.schema_sha256')),503,'Captura multilinha não conferida.');return$s;
}
private function capture(int $actor,int $draft,int $event,?int $bound=null,?int $preparer=null):array{
$s=$this->scope($actor);$binding=DB::table('mi_drafts')->where($s)->where('draft_id',$draft)->first();abort_unless($binding,403,'A captura nova exige rascunho criado neste percurso multilinha.');$view=app(DocumentPreview::class)->captureDraftForIssuance($draft,$actor);abort_unless((int)$binding->source_id===$view['source_id'],409);abort_unless(count($view['data']['lines'])>=1&&count($view['data']['lines'])<=25&&$view['data']['currency']==='MZN',422,'Seleccione1–25 linhas em MZN.');abort_unless(!empty($view['prepared']['period_start'])&&!empty($view['prepared']['period_end']),422,'Identifique a cobertura antes do cálculo documental.');$mode=match($view['prepared']['price_mode']??null){'added'=>'net_added','included'=>'gross_included',default=>abort(422,'Modalidade de preço explícita obrigatória.')};$rows=[];foreach($view['data']['lines']as$i=>$line){abort_unless(isset($view['prepared']['units'][$i])&&trim($view['prepared']['units'][$i])!==''&&$line['quantity']!==null&&$line['unit_price']!==null,422,'Quantidade/preço/unidade obrigatórios em cada linha.');$rows[]=['reference'=>'LINE-'.($i+1),'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price']];}
unset($view['consulted_at'],$view['revision'],$view['calculation'],$view['fiscal_history']);$date=now(ServiceInvoicing::ZONE)->format('Y-m-d');$common=app(FiscalRules::class)->invoiceDocument($actor,$s,$event,['currency'=>'MZN','date'=>$date,'price_mode'=>$mode,'lines'=>$rows],$bound);abort_unless($common['result']['state']==='determined_qa',409,implode(' ',$common['result']['pending']));$prepared=DB::table('users')->where('id',$preparer??$actor)->first(['id','name','email']);abort_unless($prepared,403);return ['prepared_identity'=>$this->hash((array)$prepared),'prepared_by'=>$preparer??$actor,'policy'=>self::POLICY,'document'=>$view,'common'=>$common,'expected_date'=>$date,'synthetic_only'=>true];
}
private function input(array $v):array{$d=$this->exact($v,['draft_id'=>'required|integer|min:1','rule_event_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','reason'=>'required|string|min:10|max:2000']);foreach(['draft_id','rule_event_id','draft_version','preparation_version']as$k)$d[$k]=(int)$d[$k];return$d;}
private function lock(array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();}
public function preview(int $actor,array $input,string $key):array{
$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,'fiscal_rules.prepare');return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($s);$snapshot=$this->capture($actor,$d['draft_id'],$d['rule_event_id']);$v=$snapshot['document'];abort_unless($v['draft_version']===$d['draft_version']&&$v['preparation_version']===$d['preparation_version'],409,'Versões desactualizadas.');$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'fingerprint'=>$this->hash($snapshot),'observed'=>time()];return ['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>['result'=>$snapshot['common']['result'],'new_documents'=>0,'new_receivables'=>0,'new_journals'=>0]];});
}
public function confirm(int $actor,string $preview):array{
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->input($c['input']??[]);$s=$this->scope($actor,'fiscal_rules.prepare');return DB::transaction(function()use($actor,$s,$c,$d){$this->lock($s);$this->scope($actor,'fiscal_rules.prepare');abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('mi_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409);return json_decode($old->result,true)+['replayed'=>true];}$snapshot=$this->capture($actor,$d['draft_id'],$d['rule_event_id']);$fingerprint=$this->hash($snapshot);abort_unless(hash_equals($c['fingerprint'],$fingerprint),409,'Origem/regra/versão/data alteradas. Confira novamente.');$existing=DB::table('mi_calculations')->where($s+['draft_id'=>$d['draft_id'],'fingerprint'=>$fingerprint])->first();$id=$existing?->id??DB::table('mi_calculations')->insertGetId($s+['draft_id'=>$d['draft_id'],'actor_id'=>$actor,'fingerprint'=>$fingerprint,'snapshot'=>Payables::canonical($snapshot),'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'draft_id'=>$d['draft_id'],'type'=>'calculation','rule_event_id'=>$d['rule_event_id'],'draft_version'=>$d['draft_version'],'preparation_version'=>$d['preparation_version'],'date'=>$snapshot['expected_date'],'result'=>$snapshot['common']['result'],'policy'=>self::POLICY];DB::table('mi_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>Payables::canonical($d),'result'=>Payables::canonical($result),'created_at'=>now()->utc()]);Audit::record('service_invoice.multiline_calculated',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'reason'=>$d['reason'],'review_hash'=>$fingerprint]);return$result+['replayed'=>false];});
}
public function current(int $actor,int $id,?int $draft=null):array{
$s=$this->scope($actor);$r=DB::table('mi_calculations')->where($s)->where('id',$id)->first();abort_unless($r&&($draft===null||(int)$r->draft_id===$draft),403,'Cálculo fora do rascunho/contexto.');$this->permissions->require((int)$r->actor_id,$s['tenant_id'],$s['company_id'],'fiscal_rules.prepare',$s['unit_id']);abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$r->actor_id])->exists(),403);$snapshot=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);$now=$this->capture($actor,(int)$r->draft_id,(int)$snapshot['common']['rule']['id'],$id,(int)$r->actor_id);abort_unless(hash_equals($r->fingerprint,$this->hash($now)),409,'Cálculo anterior à origem/regra/data actual.');return ['id'=>$r->id,'fingerprint'=>$r->fingerprint,'snapshot'=>$snapshot];
}
public static function financialAmounts(array $snapshot):array{
abort_unless(($snapshot['calculation_policy']??null)===self::POLICY&&($snapshot['renderer']??null)===MultiLineInvoicePdf::VERSION,409,'Tipo de fotografia multilinha incompatível.');$c=$snapshot['calculation'];$r=$c['common_document_result'];$lines=$snapshot['document']['data']['lines'];abort_unless(count($lines)>=1&&count($lines)<=25&&($snapshot['document']['data']['currency']??null)==='MZN',409);$in=[];foreach($lines as$i=>$line)$in[]=['reference'=>'LINE-'.($i+1),'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price']];$mode=match($snapshot['document']['prepared']['price_mode']){'added'=>'net_added','included'=>'gross_included',default=>abort(409)};$expected=FiscalDocumentMath::calculate($in,$mode,$r['rate']);$frozen=$r;unset($frozen['currency'],$frozen['rule']);abort_unless(Payables::canonical($expected)===Payables::canonical($frozen)&&$expected['state']==='determined_qa'&&$c['base_document_proposed']===$r['base_document']&&$c['vat_document_proposed']===$r['tax_document']&&$c['total_document_proposed']===$r['total'],409,'Valores documentais por linha não conciliam com a fotografia.');return['base'=>$r['base_document'],'tax'=>$r['tax_document'],'total'=>$r['total']];
}
public function receipt(int $actor,string $key):array{$s=$this->scope($actor);$this->exact(['key'=>$key],['key'=>'required|uuid']);$r=DB::table('mi_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'historical_receipt_only'=>true,'operation_executed'=>false];}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Modules\Services;
use Illuminate\Support\Facades\{DB,Validator};
use Brick\Math\BigDecimal;
/** Typed readiness and photograph only. ServiceInvoicing retains numbering, invoice and receipt writes. */
final class MultiLineInvoiceIssuance
{
public function temporal(int $actor,array $input,array $scope,?array $previous=null):array{
abort_if(array_diff(array_keys($input),['calculation_policy','common_calculation_id','common_rule_event_id','draft_id','draft_version','preparation_version','valid_from','valid_until','period_start','period_end','issue_window_start','issue_window_end','event','readiness_basis','evidence']),422);$d=Validator::make($input,['calculation_policy'=>'required|in:'.MultiLineInvoiceCalculation::POLICY,'common_calculation_id'=>'required|integer|min:1','common_rule_event_id'=>'required|integer|min:1','draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','period_start'=>'required|date_format:Y-m-d','period_end'=>'required|date_format:Y-m-d|after_or_equal:period_start','issue_window_start'=>'required|date_format:Y-m-d','issue_window_end'=>'required|date_format:Y-m-d|after_or_equal:issue_window_start','event'=>'required|in:on_issue_within_deadline','readiness_basis'=>'required|string|min:10|max:4000','evidence'=>'required|string|min:10|max:4000'])->validate();foreach(['common_calculation_id','common_rule_event_id','draft_id','draft_version','preparation_version']as$k)$d[$k]=(int)$d[$k];$calc=app(MultiLineInvoiceCalculation::class)->current($actor,$d['common_calculation_id'],$d['draft_id']);$snapshot=$calc['snapshot'];$view=$snapshot['document'];abort_unless($d['draft_version']===$view['draft_version']&&$d['preparation_version']===$view['preparation_version']&&$d['period_start']===($view['prepared']['period_start']??null)&&$d['period_end']===($view['prepared']['period_end']??null)&&$d['common_rule_event_id']===$snapshot['common']['rule']['id'],409,'A regra temporal não corresponde à captura revista.');if($previous)abort_unless((int)$previous['draft_id']===$d['draft_id']&&($previous['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,409,'A regra temporal não pode trocar de origem/política.');$d['calculation_fingerprint']=$calc['fingerprint'];return$d;
}
private function proposal(int $actor,int $id,array $s,string $date):array{
abort_unless(app(MultiLineInvoiceCalculation::class)->scope($actor)===$s,403);$r=DB::table('si_proposals')->where($s)->where('id',$id)->first();abort_unless($r,403);$d=json_decode($r->data,true);$approved=DB::table('si_decisions')->where(['proposal_id'=>$r->id,'decision'=>'approved'])->exists()&&$d['valid_from']<=$date&&(!isset($d['valid_until'])||$d['valid_until']===''||$date<=$d['valid_until'])&&!DB::table('si_proposals as n')->where($s)->where(['kind'=>$r->kind,'identifier'=>$r->identifier])->where('version','>',$r->version)->whereRaw("data->>'valid_from'<=?",[$date])->whereExists(fn($q)=>$q->selectRaw('1')->from('si_decisions as d')->whereColumn('d.proposal_id','n.id')->where('d.decision','approved'))->exists();return ['row'=>$r,'data'=>$d,'approved'=>$approved];
}
public function review(int $draft,int $series,int $rule,int $actor,array $s):array{
$date=now(ServiceInvoicing::ZONE)->format('Y-m-d');$p=$this->proposal($actor,$series,$s,$date);$t=$this->proposal($actor,$rule,$s,$date);$d=$t['data'];$blocks=[];abort_unless(($d['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,422);$calc=app(MultiLineInvoiceCalculation::class)->current($actor,(int)$d['common_calculation_id'],$draft);$snap=$calc['snapshot'];$view=$snap['document'];$common=$snap['common'];
if($p['row']->kind!=='series'||!$p['approved']||($p['data']['state']??null)!=='open'||!str_starts_with($p['data']['code']??'','TEST-MULTI-'))$blocks[]='Série de teste exacta não revista/vigente/aberta.';if($t['row']->kind!=='temporal'||!$t['approved']||(int)$d['draft_id']!==$draft)$blocks[]='Regra temporal exacta não revista/vigente.';
if(($d['calculation_fingerprint']??null)!==$calc['fingerprint']||$d['draft_version']!==$view['draft_version']||$d['preparation_version']!==$view['preparation_version']||$d['period_start']!==($view['prepared']['period_start']??null)||$d['period_end']!==($view['prepared']['period_end']??null))$blocks[]='Origem/versão/período alterados; exige nova conferência.';
if($date<$d['issue_window_start']||$date>$d['issue_window_end'])$blocks[]='Data actual fora da janela de emissão fundamentada.';
foreach(['issuer'=>'Emitente','client'=>'Cliente']as$party=>$label)foreach(['name','nuit','address','locality','country']as$f){$v=$f==='name'?($view[$party]['name']??''):($view[$party]['data'][$f]??'');if(!is_string($v)||trim($v)==='')$blocks[]=$label.': '.$f.' por completar.';}
if(DB::table('si_invoices')->where('draft_id',$draft)->orWhere('source_id',$view['source_id'])->exists())$blocks[]='Já existe documento desta origem.';
$r=$common['result'];try{$minor=BigDecimal::of($r['total'])->multipliedBy(100)->toInt();if($minor<=0)$blocks[]='Documento financeiro exige total positivo.';if(BigDecimal::of($r['base_document'])->isLessThanOrEqualTo(0)||BigDecimal::of($r['tax_document'])->isLessThanOrEqualTo(0))$blocks[]='O plano contabilístico QA de três linhas exige base e imposto positivos.';}catch(\Brick\Math\Exception\MathException){$blocks[]='Montante fora da representação contabilística inteira.';}$def=$common['rule'];$rate=(string)BigDecimal::of($def['definition']['parameters']['rate'])->multipliedBy(100)->stripTrailingZeros();$result=['policy'=>MultiLineInvoiceCalculation::POLICY,'currency'=>'MZN','state'=>'synthetic_proposed','price_mode'=>$view['prepared']['price_mode'],'base_exact'=>$r['base_exact_fraction'],'base_document_proposed'=>$r['base_document'],'vat_exact'=>$r['tax_exact_fraction'],'vat_document_proposed'=>$r['tax_document'],'discarded_difference'=>$r['tax_difference_fraction'],'total_document_proposed'=>$r['total'],'common_document_result'=>$r,'common_rule'=>$def,'pending'=>[]];$profile=['kind'=>'synthetic_common_rule','rate_percent'=>$rate,'valid_from'=>$def['definition']['valid_from'],'valid_until'=>$def['definition']['valid_until'],'source_sha256'=>$def['definition']['source_sha256']];
$entry=['id'=>$calc['id'],'snapshot'=>['input'=>['policy'=>MultiLineInvoiceCalculation::POLICY,'profile_identifier'=>$def['rule_key'],'profile_version'=>$def['rule_version'],'profile'=>$profile,'common_capture'=>$snap],'result'=>$result]];
$payload=['calculation_policy'=>MultiLineInvoiceCalculation::POLICY,'draft_id'=>$draft,'series_id'=>$series,'series_version'=>(int)$p['row']->version,'series_code'=>$p['data']['code'],'series_definition'=>$p['data'],'rule_id'=>$rule,'rule_version'=>(int)$t['row']->version,'rule'=>$d,'expected_date'=>$date,'document'=>$view,'calculation_entry'=>$entry,'profile'=>$profile,'blocks'=>$blocks];
if(!$blocks){$sample=app(ServiceInvoicing::class)->multiLineSnapshot($payload,0,now()->toIso8601String());if(!InvoicePdf::supports(MultiLineInvoicePdf::lines($sample)))$payload['blocks'][]='Texto não representável no PDF sem perda; confira os dados.';}
return ['ready'=>$payload['blocks']===[],'notice'=>ServiceInvoicing::NOTICE,'payload'=>$payload,'review_hash'=>hash_hmac('sha256',json_encode($payload,JSON_THROW_ON_ERROR),(string)config('app.key'))];
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Services;
final class MultiLineInvoicePdf
{
public const VERSION='sgf-test-invoice-multiline-text-v1';
public static function lines(array $s):array{
$c=$s['calculation'];$r=$c['common_document_result'];$p=$s['document']['prepared'];$rows=[['title','Factura de servicos - TESTE QA'],['text',$s['series_code'].' / '.$s['number']],['text','Emissao: '.$s['issued_local'].' ('.$s['timezone'].')'],['text',ServiceInvoicing::NOTICE]];
foreach(['issuer'=>'Emitente','client'=>'Cliente']as$k=>$label){$party=$s['document'][$k];$rows[]=['section',$label];$rows[]=['text',$party['name']];foreach(['nuit'=>'NUIT','address'=>'Morada','locality'=>'Localidade','country'=>'Pais']as$f=>$n)$rows[]=['text',$n.': '.($party['data'][$f]??'Nao indicado')];}
$rows[]=['section','Linhas do documento'];$rows[]=['text','Modalidade: '.($r['price_mode']==='gross_included'?'preco com imposto incluido':'preco sem imposto, acrescido')];
foreach($s['document']['data']['lines']as$i=>$line){$v=$r['lines'][$i];$rows[]=['section','Linha '.($i+1).' - '.$line['service_name']];if(!empty($line['description']))$rows[]=['text',$line['description']];$rows[]=['text','Quantidade '.$line['quantity'].' | Unidade '.$p['units'][$i].' | Preco '.$line['unit_price']];$rows[]=['text','Base documental '.$v['base_document'].' | Imposto '.$v['tax_document'].' | Total '.$v['total_document']];$rows[]=['text','Base exacta '.$v['base_exact_fraction'].' | Imposto exacto '.$v['tax_exact_fraction']];$rows[]=['text','Diferenca base '.$v['base_difference_fraction'].' | Diferenca imposto '.$v['tax_difference_fraction']];}
$rows[]=['section','Totais MZN - soma dos valores documentais por linha'];$rows[]=['text','Base '.$r['base_document'].' | Imposto '.$r['tax_document'].' | Total '.$r['total']];$rows[]=['text','Regra QA '.$s['profile_identifier'].' v'.$s['profile_version'].' | Taxa artificial '.$s['rate_percent'].'%'];$rows[]=['text','Politica '.$r['policy']];$rows[]=['text','Periodo '.$p['period_start'].' a '.$p['period_end']];$rows[]=['text','Condicoes: '.($p['payment_terms']??'Nao indicadas')];$rows[]=['text','Vencimento proposto: '.($p['proposed_due_date']??'Nao indicado')];$rows[]=['text','Origem: rascunho #'.$s['draft_id'].' v'.$s['document']['draft_version'].' | Regra temporal #'.$s['rule_id'].' v'.$s['rule_version']];$rows[]=['text','Emissao nao comprova recebimento ou aplicacao contabilistica. Sem comunicacao a AT.'];return$rows;
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Modules\Services;
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Support\Facades\DB;
/** Qualified new dependencies keep Commercial ownership and require existing target-module rights. */
final class QualifiedInvoiceSource
{
public static function query(){
$legacy=ModuleRecords::query('si_invoices');$module=ModuleRecords::active();if(config('multiline_invoices.enabled')!==true||!in_array($module,['finance','accounting'],true))return$legacy;CompositionRuntime::assert();$c=app(CompanyContext::class);$s=$c->identifiers()+['unit_id'=>$c->unitId()];$actor=$c->userId();if(!$s['unit_id']||!app(Permissions::class)->has($actor,$s['tenant_id'],$s['company_id'],$module==='finance'?'service_receivables.view':'general_ledger.view',$s['unit_id']))return$legacy;
$owned=DB::table('a_billing_drafts');ModuleRecords::constrain($owned,'a_billing_drafts','billing_drafts','commercial');$owned->where($s);
return DB::table('si_invoices')->where($s)->where(function($q)use($legacy,$s,$owned){$q->whereIn('id',$legacy->select('id'))->orWhere(function($q)use($s,$owned){$q->whereRaw("si_invoices.snapshot::jsonb->>'calculation_policy'=?",[MultiLineInvoiceCalculation::POLICY])->whereExists(function($m)use($s,$owned){$m->selectRaw('1')->from('mi_drafts as md')->whereColumn('md.draft_id','si_invoices.draft_id')->whereColumn('md.source_id','si_invoices.source_id')->where(['md.tenant_id'=>$s['tenant_id'],'md.company_id'=>$s['company_id'],'md.unit_id'=>$s['unit_id']])->whereIn('md.draft_id',$owned->select('id'));});});});
}
}
+11 -5
View File
@@ -68,6 +68,9 @@ final class ServiceInvoicing
abort_unless($d['code']===$in['identifier'],422,'Identidade da série deve coincidir com o código sintético.');
if($previous) { $old=$this->data($previous); abort_unless($old['code']===$d['code'] && (int)$old['first_number']===(int)$d['first_number'],422,'Não reiniciar ou transferir contador existente.'); }
$d['first_number']=(int)$d['first_number'];
} elseif(($in['data']['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY){
$d=app(MultiLineInvoiceIssuance::class)->temporal($actor,$in['data'],$s,$previous?$this->data($previous):null);
abort_if(DB::table('si_proposals')->where($s)->where('kind','temporal')->whereRaw("data->>'draft_id' = ?",[(string)$d['draft_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade temporal existente.');
} else {
$d=$this->exact($in['data'],$common+['draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','profile_proposal_id'=>'required|integer|min:1','period_start'=>'required|date_format:Y-m-d','period_end'=>'required|date_format:Y-m-d|after_or_equal:period_start','issue_window_start'=>'required|date_format:Y-m-d','issue_window_end'=>'required|date_format:Y-m-d|after_or_equal:issue_window_start','event'=>'required|in:on_issue_within_deadline','readiness_basis'=>'required|string|max:4000']);
$view=$this->preview->captureDraftForIssuance((int)$d['draft_id'],$actor);
@@ -90,6 +93,7 @@ final class ServiceInvoicing
$d=$this->exact($input,['version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|max:4000']);
$p=$this->row($id,$s); abort_unless((int)$p->version===(int)$d['version'] && (int)$p->actor_id!==$actor,403,'Validador distinto e versão exacta obrigatórios.');
abort_if($this->decision($id),409,'Decisão imutável; correcção exige nova versão.');
$mi=$this->data($p);if(($mi['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY){$fingerprint=$mi['calculation_fingerprint'];unset($mi['calculation_fingerprint']);$fresh=app(MultiLineInvoiceIssuance::class)->temporal($actor,$mi,$s);abort_unless(hash_equals($fingerprint,$fresh['calculation_fingerprint']),409,'A captura mudou; confira nova proposta.');}
DB::table('si_decisions')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$d['decision'],'reason'=>$d['reason'],'created_at'=>now()]);
Audit::record('service_invoice.decided',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$p->version,'reason'=>$d['reason']]);
return ['id'=>$id,'decision'=>$d['decision']];
@@ -101,7 +105,7 @@ final class ServiceInvoicing
if(!ServiceCalculation::isolated()) return ['real_blocked'=>true,'test_enabled'=>false,'notice'=>'Emissão real bloqueada: conformidade ainda não demonstrada.','proposals'=>[],'invoices'=>[]];
TestingDatabaseGuard::assertSafe();
if(!Schema::hasTable('si_invoices')) return ['real_blocked'=>true,'notice'=>'Migração documental ainda não aplicada.','proposals'=>[],'invoices'=>[]];
return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>QualifiedInvoiceSource::query()->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
}
public function review(int $draft,int $series,int $rule,int $actor): array
{
@@ -115,6 +119,7 @@ final class ServiceInvoicing
}
private function reviewInside(int $draft,int $series,int $rule,int $actor,array $s): array
{
$mi=$this->data($this->row($rule,$s));if(($mi['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY)return app(MultiLineInvoiceIssuance::class)->review($draft,$series,$rule,$actor,$s);
$view=$this->preview->captureDraftForIssuance($draft,$actor); $date=now(self::ZONE)->format('Y-m-d'); $blocks=[];
$p=$this->row($series,$s); $t=$this->row($rule,$s); $sd=$this->data($p); $td=$this->data($t);
if($p->kind!=='series' || !$this->approved($p,$s,$date) || ($sd['state'] ?? null)!=='open') $blocks[]='Série sintética sem configuração aprovada/vigente e aberta.';
@@ -147,11 +152,12 @@ final class ServiceInvoicing
}
private function snapshot(array $p,int $number,string $instant): array
{
return ['notice'=>self::NOTICE,'renderer'=>InvoicePdf::VERSION,'draft_id'=>$p['draft_id'],'series_code'=>$p['series_code'],'number'=>$number,'issued_at'=>$instant,'issued_local'=>\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d H:i:s'),'timezone'=>self::ZONE,
return ['notice'=>self::NOTICE,'renderer'=>isset($p['calculation_policy'])?MultiLineInvoicePdf::VERSION:InvoicePdf::VERSION,'draft_id'=>$p['draft_id'],'series_code'=>$p['series_code'],'number'=>$number,'issued_at'=>$instant,'issued_local'=>\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d H:i:s'),'timezone'=>self::ZONE,
'series_id'=>$p['series_id'],'series_version'=>$p['series_version'],'series_definition'=>$p['series_definition'],'rule_id'=>$p['rule_id'],'rule_version'=>$p['rule_version'],'rule'=>$p['rule'],'document'=>$p['document'],
'calculation'=>array_replace($p['calculation_entry']['snapshot']['result'],['temporal_state'=>'issue_simulated','exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'pending'=>[]]),'prior_calculation_snapshot'=>$p['calculation_entry']['snapshot'],'calculation_id'=>$p['calculation_entry']['id'],'profile_identifier'=>$p['calculation_entry']['snapshot']['input']['profile_identifier'],'profile_version'=>$p['calculation_entry']['snapshot']['input']['profile_version'],'profile'=>$p['profile'],'rate_percent'=>$p['profile']['rate_percent'],
'exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'simulation_only'=>true];
}
public function multiLineSnapshot(array $payload,int $number,string $instant):array{abort_unless(($payload['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY,422);return ['calculation_policy'=>MultiLineInvoiceCalculation::POLICY]+$this->snapshot($payload,$number,$instant);}
public function issue(int $draft,int $actor,array $input,string $key): array
{
return $this->command($actor,'service_invoices.issue',function($s)use($draft,$actor,$input,$key) {
@@ -170,7 +176,7 @@ final class ServiceInvoicing
DB::table('si_counters')->where('id',$counter->id)->update(['last_number'=>$number]);
$instant=now()->toIso8601String();
abort_unless(\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d')===$p['expected_date'],409,'Data mudou durante a confirmação; reveja antes de emitir.');
$snapshot=$this->snapshot($p,$number,$instant);
$snapshot=isset($p['calculation_policy'])?$this->multiLineSnapshot($p,$number,$instant):$this->snapshot($p,$number,$instant);
$snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes.
$bytes=$this->pdf->render($snapshot);
$id=\App\Shared\Context\ModuleRecords::query('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]);
@@ -185,13 +191,13 @@ final class ServiceInvoicing
public function show(int $id,int $actor): array
{
self::guard(); $s=$this->scope($actor,'service_invoices.view');
$r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
$r=QualifiedInvoiceSource::query()->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
$snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR);
return ['id'=>$r->id,'snapshot'=>$snapshot,'document_lines'=>$snapshot['document_lines'] ?? InvoicePdf::lines($snapshot),'pdf_sha256'=>$r->pdf_sha256,'corrections'=>DB::table('si_correction_requests')->where('invoice_id',$id)->get(['id','reason','actor_id','created_at'])->all()];
}
public function pdf(int $id,int $actor): string
{
$this->show($id,$actor); $r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']);
$this->show($id,$actor); $r=QualifiedInvoiceSource::query()->where('id',$id)->first(['pdf_base64','pdf_sha256']);
$bytes=base64_decode($r->pdf_base64,true); abort_unless($bytes!==false && hash_equals($r->pdf_sha256,hash('sha256',$bytes)),409,'Integridade do PDF não confirmada.'); return $bytes;
}
public function requestCorrection(int $id,int $actor,array $input): array
+2 -2
View File
@@ -90,7 +90,7 @@ final class ServiceReceivables
$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=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
$inv=QualifiedInvoiceSource::query()->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.');
@@ -252,7 +252,7 @@ final class ServiceReceivables
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'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
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'=>QualifiedInvoiceSource::query()->where($s)->get(['id','series_code','number'])->all()];
});
}
/** Scoped single-position read reuses the authoritative state calculation without writer locks. */
+3 -1
View File
@@ -13,7 +13,7 @@ final class ModuleNavigation
'purchasing'=>['purchasing_budget.', 'purchasing_requisitions.', 'purchasing_receipts.', 'purchasing_orders.', 'purchasing_sourcing.', 'purchasing_suppliers.', 'purchasing_requests.', 'purchasing_invoices.', 'purchasing_obligations.', 'supplier_payables.'],
'stock'=>['stock.'], 'workshop'=>['workshop.', 'workshop_billing.', 'workshop_corrections.'],
'finance'=>['financial_analysis.','bank_reconciliation.','cash.', 'direct_costs.', 'industrial_costs.', 'industrial_budgets.', 'industrial_budget_comparisons.', 'service_receivables.'],
'accounting'=>['fiscal_obligations.','fiscal_rules.','fiscal_assessments.','fiscal_forms.','catalogue_accounting.', 'service_fiscal.', 'general_ledger.'],
'accounting'=>['fixed_assets.','fiscal_obligations.','fiscal_rules.','fiscal_assessments.','fiscal_forms.','catalogue_accounting.', 'service_fiscal.', 'general_ledger.'],
];
public function available(CompanyContext $context): array
@@ -53,7 +53,9 @@ final class ModuleNavigation
{
if(preg_match('~^(stock/catalogue|api/stock-catalogue)(/|$)~',$path))return 'stock';
if(preg_match('~^(finance/cash|api/cash)(/|$)~',$path))return 'finance';
if(preg_match('~^api/multiline-invoices(/|$)~',$path))return 'commercial';
if(preg_match('~^api/(remunerations|hr-personnel)(/|$)~',$path))return 'human_resources';
if(preg_match('~^(accounting/assets|api/fixed-assets)(/|$)~',$path))return 'accounting';
if(preg_match('~^api/fiscal-obligations(/|$)~',$path))return 'accounting';
if(preg_match('~^api/finance/payable-positions(/|$)~',$path))return 'finance';
if(preg_match('~^api/finance/payable-due(/|$)~',$path))return 'finance';
@@ -17,7 +17,7 @@ final class PresentationEntry
'workshop'=>['sgf:workshop_manage','/?start=workshop','Oficinas','Equipamento, cliente, agenda, ordem, autorização e apontamentos verificados'],
'finance'=>['sgf:read_only','/?start=service_receivables','Financeiro','Recebíveis, obrigações, Caixa, conferência de extracto e indicadores'],
'accounting'=>['sgf:read_only','/general-ledger','Contabilidade','Origens, diário integrado, plano, balancete, razão e mapas fiscais internos'],
'human_resources'=>['hr:hr_read','/?start=remunerations','Recursos Humanos','Cadastro fictício, regras artificiais, processamento interno e revisão distinta; acesso próprio'],
'human_resources'=>['hr:hr_read','/?start=remunerations','Recursos Humanos','Cadastro, contratos, assiduidade, férias e remunerações internas QA; acesso próprio e revisão distinta'],
];
private function guard():void
{
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['enabled'=>false,'schema_sha256'=>null,'native_schema_sha256'=>null,'access_sha256'=>null];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['enabled'=>false,'schema_sha256'=>null,'draft_schema_sha256'=>null];
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE fa_meta(policy text PRIMARY KEY,composition text NOT NULL,source_hash char(64) NOT NULL);
CREATE TABLE fa_versions(
id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,
kind text NOT NULL CHECK(kind IN('asset','plan','charge','disposal')),record_key uuid NOT NULL,version bigint NOT NULL CHECK(version>0),previous_id bigint UNIQUE REFERENCES fa_versions(id),
action text NOT NULL CHECK(action IN('prepare','review')),state text NOT NULL CHECK(state IN('prepared','approved','refused')),
prepared_by bigint NOT NULL REFERENCES users(id),actor_id bigint NOT NULL REFERENCES users(id),
preparer_identity char(64) NOT NULL,data jsonb NOT NULL,source_basis char(64) NOT NULL,
impact jsonb NOT NULL,reason text NOT NULL CHECK(length(reason)>=10),created_at timestamptz NOT NULL,
UNIQUE(tenant_id,company_id,unit_id,record_key,version),
FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id),
CHECK(jsonb_typeof(data)='object'),CHECK(jsonb_typeof(impact)='object'),
CHECK((action='prepare' AND state='prepared') OR (action='review' AND state IN('approved','refused'))),
CHECK(action<>'review' OR prepared_by<>actor_id)
);
CREATE TABLE fa_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),command_key uuid NOT NULL,request_hash char(64) NOT NULL,
result jsonb NOT NULL,input jsonb NOT NULL,created_at timestamptz NOT NULL,
UNIQUE(tenant_id,company_id,unit_id,actor_id,command_key)
);
CREATE FUNCTION fa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'SGF_FIXED_ASSETS_APPEND_ONLY'; END; $$;
CREATE TRIGGER fa_meta_immutable BEFORE UPDATE OR DELETE ON fa_meta FOR EACH ROW EXECUTE FUNCTION fa_append_only();
CREATE TRIGGER fa_versions_immutable BEFORE UPDATE OR DELETE ON fa_versions FOR EACH ROW EXECUTE FUNCTION fa_append_only();
CREATE TRIGGER fa_commands_immutable BEFORE UPDATE OR DELETE ON fa_commands FOR EACH ROW EXECUTE FUNCTION fa_append_only();
CREATE INDEX fa_versions_scope_latest ON fa_versions(tenant_id,company_id,unit_id,record_key,version DESC);
CREATE INDEX fa_versions_kind ON fa_versions(tenant_id,company_id,unit_id,kind,id DESC);
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE gl_asset_books(tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,policy text NOT NULL,host_database text NOT NULL,schema_sha256 char(64) NOT NULL,PRIMARY KEY(tenant_id,company_id,unit_id));
CREATE FUNCTION gl_asset_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE s jsonb;k text;cost bigint;acc bigint;loss bigint;g gl_correction_results%ROWTYPE;o gl_journals%ROWTYPE;expected text[];
BEGIN
s:=NEW.snapshot->'source';k:=s->>'effect_type';IF k NOT LIKE 'assets.%' THEN RETURN NEW; END IF;
IF NOT EXISTS(SELECT 1 FROM gl_asset_books b WHERE (b.tenant_id,b.company_id,b.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id) AND b.policy='QA-ASSET-JOURNAL-v1' AND b.host_database=s->>'host_database') OR NEW.snapshot->'rule'->>'policy'<>'QA-ASSET-JOURNAL-v1' OR NEW.snapshot->'rule'->>'effect_type'<>k OR (s->>'tax_minor')::bigint<>0 OR (s->>'amount_minor')::bigint<>NEW.debit_minor THEN RAISE EXCEPTION 'ASSET_SOURCE_OR_RULE' USING ERRCODE='23514';END IF;
expected:=CASE k WHEN 'assets.admitted' THEN ARRAY['QA-ACTIVO-CUSTO','QA-ACTIVO-ORIGEM-QA'] WHEN 'assets.depreciation' THEN ARRAY['QA-ACTIVO-GASTO-DEP','QA-ACTIVO-DEP-ACUM'] WHEN 'assets.depreciation.replacement' THEN ARRAY['QA-ACTIVO-GASTO-DEP','QA-ACTIVO-DEP-ACUM'] WHEN 'assets.depreciation.reversal' THEN ARRAY['QA-ACTIVO-DEP-ACUM','QA-ACTIVO-GASTO-DEP'] ELSE NULL END;
IF k='assets.writeoff' THEN
cost:=(s->>'cost_minor')::bigint;acc:=(s->>'accumulated_minor')::bigint;loss:=(s->>'carrying_minor')::bigint;
IF cost<>NEW.debit_minor OR acc<0 OR loss<0 OR acc+loss<>cost THEN RAISE EXCEPTION 'ASSET_WRITEOFF_VALUES' USING ERRCODE='23514';END IF;
expected:=CASE WHEN acc=0 THEN ARRAY['QA-ACTIVO-GASTO-ABATE','QA-ACTIVO-CUSTO'] WHEN loss=0 THEN ARRAY['QA-ACTIVO-DEP-ACUM','QA-ACTIVO-CUSTO'] ELSE ARRAY['QA-ACTIVO-DEP-ACUM','QA-ACTIVO-GASTO-ABATE','QA-ACTIVO-CUSTO'] END;
END IF;
IF expected IS NULL OR jsonb_array_length(NEW.snapshot->'lines')<>cardinality(expected) OR EXISTS(SELECT 1 FROM jsonb_array_elements(NEW.snapshot->'lines') WITH ORDINALITY x(line,n) WHERE x.line->>'account_code'<>expected[x.n]) THEN RAISE EXCEPTION 'ASSET_ACCOUNT_LINES' USING ERRCODE='23514';END IF;
IF k IN('assets.depreciation.reversal','assets.depreciation.replacement') THEN
SELECT * INTO g FROM gl_correction_results WHERE reverse_journal_id=NEW.id OR replacement_journal_id=NEW.id;
SELECT * INTO o FROM gl_journals WHERE id=g.original_journal_id AND (tenant_id,company_id,unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id);
IF g.id IS NULL OR o.id IS NULL OR o.snapshot->'source'->>'effect_type'<>'assets.depreciation' OR (s->>'source_id')::bigint<>o.id OR s->>'asset_key'<>o.snapshot->'source'->>'asset_key' OR (k='assets.depreciation.reversal' AND NEW.debit_minor<>o.debit_minor) THEN RAISE EXCEPTION 'ASSET_CORRECTION_PAIR_REQUIRED' USING ERRCODE='23514';END IF;
END IF;
RETURN NEW;
END $$;
CREATE CONSTRAINT TRIGGER gl_asset_journal_guard AFTER INSERT ON gl_journals DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION gl_asset_guard();
CREATE TRIGGER gl_asset_books_immutable BEFORE UPDATE OR DELETE ON gl_asset_books FOR EACH ROW EXECUTE FUNCTION gl_append_only();
REVOKE ALL ON TABLE gl_asset_books FROM PUBLIC;
REVOKE ALL ON FUNCTION gl_asset_guard() FROM PUBLIC;
-- ASSET_PAIR_EXTENSION_v1
CREATE TABLE gl_asset_extension_versions(version int PRIMARY KEY,source_hash char(64) NOT NULL);
CREATE TRIGGER gl_asset_extension_versions_immutable BEFORE UPDATE OR DELETE ON gl_asset_extension_versions FOR EACH ROW EXECUTE FUNCTION gl_append_only();
REVOKE ALL ON TABLE gl_asset_extension_versions FROM PUBLIC;
CREATE FUNCTION gl_asset_pair_assert(group_id bigint) RETURNS void LANGUAGE plpgsql AS $$
DECLARE g gl_corrections%ROWTYPE;r gl_correction_results%ROWTYPE;o gl_journals%ROWTYPE;a gl_journals%ROWTYPE;b gl_journals%ROWTYPE;
BEGIN
SELECT * INTO STRICT g FROM gl_corrections WHERE id=group_id;
SELECT * INTO STRICT r FROM gl_correction_results WHERE correction_id=g.id AND (tenant_id,company_id,unit_id)=(g.tenant_id,g.company_id,g.unit_id);
SELECT * INTO STRICT o FROM gl_journals WHERE id=g.original_journal_id AND (tenant_id,company_id,unit_id)=(g.tenant_id,g.company_id,g.unit_id);
SELECT * INTO STRICT a FROM gl_journals WHERE id=r.reverse_journal_id AND proposal_id=g.reverse_proposal_id AND (tenant_id,company_id,unit_id)=(g.tenant_id,g.company_id,g.unit_id);
SELECT * INTO STRICT b FROM gl_journals WHERE id=r.replacement_journal_id AND proposal_id=g.replacement_proposal_id AND (tenant_id,company_id,unit_id)=(g.tenant_id,g.company_id,g.unit_id);
IF r.original_journal_id<>o.id OR r.actor_id<>g.prepared_by OR a.posted_by<>g.prepared_by OR b.posted_by<>g.prepared_by OR o.snapshot->'source'->>'effect_type'<>'assets.depreciation' OR a.snapshot->'source'->>'effect_type'<>'assets.depreciation.reversal' OR b.snapshot->'source'->>'effect_type'<>'assets.depreciation.replacement' OR (a.snapshot->'source'->>'source_id')::bigint<>o.id OR (b.snapshot->'source'->>'source_id')::bigint<>o.id OR a.debit_minor<>o.debit_minor OR a.snapshot->'source'->>'asset_key'<>o.snapshot->'source'->>'asset_key' OR b.snapshot->'source'->>'asset_key'<>o.snapshot->'source'->>'asset_key' OR a.snapshot->'source'->>'replacement_source_revision'<>b.snapshot->'source'->>'replacement_source_revision' THEN RAISE EXCEPTION 'ASSET_CORRECTION_PAIR_REQUIRED' USING ERRCODE='23514';END IF;
END $$;
REVOKE ALL ON FUNCTION gl_asset_pair_assert(bigint) FROM PUBLIC;
@@ -0,0 +1,7 @@
CREATE TABLE mi_draft_meta(policy text PRIMARY KEY,composition text NOT NULL,source_hash char(64) NOT NULL);
CREATE TABLE mi_drafts(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,draft_id bigint UNIQUE NOT NULL REFERENCES a_billing_drafts(id),source_id bigint UNIQUE NOT NULL REFERENCES a_service_drafts(id),actor_id bigint NOT NULL REFERENCES users(id),created_at timestamptz NOT NULL,FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id));
CREATE TRIGGER mi_draft_meta_immutable BEFORE UPDATE OR DELETE ON mi_draft_meta FOR EACH ROW EXECUTE FUNCTION mi_append_only();
CREATE TRIGGER mi_drafts_immutable BEFORE UPDATE OR DELETE ON mi_drafts FOR EACH ROW EXECUTE FUNCTION mi_append_only();
CREATE INDEX mi_drafts_scope ON mi_drafts(tenant_id,company_id,unit_id,id DESC);
REVOKE ALL ON TABLE mi_draft_meta,mi_drafts FROM PUBLIC;
REVOKE ALL ON SEQUENCE mi_drafts_id_seq FROM PUBLIC;
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE mi_meta(policy text PRIMARY KEY,composition text NOT NULL,source_hash char(64) NOT NULL);
CREATE TABLE mi_calculations(
id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,
draft_id bigint NOT NULL REFERENCES a_billing_drafts(id),actor_id bigint NOT NULL REFERENCES users(id),
fingerprint char(64) NOT NULL,snapshot jsonb NOT NULL,reason text NOT NULL CHECK(length(reason)>=10),created_at timestamptz NOT NULL,
UNIQUE(tenant_id,company_id,unit_id,draft_id,fingerprint),
FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE TABLE mi_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),command_key uuid NOT NULL,request_hash char(64) NOT NULL,input jsonb NOT NULL,result jsonb NOT NULL,created_at timestamptz NOT NULL,UNIQUE(tenant_id,company_id,unit_id,actor_id,command_key));
CREATE FUNCTION mi_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'MULTILINE_INVOICE_APPEND_ONLY';END $$;
CREATE TRIGGER mi_meta_immutable BEFORE UPDATE OR DELETE ON mi_meta FOR EACH ROW EXECUTE FUNCTION mi_append_only();
CREATE TRIGGER mi_calculations_immutable BEFORE UPDATE OR DELETE ON mi_calculations FOR EACH ROW EXECUTE FUNCTION mi_append_only();
CREATE TRIGGER mi_commands_immutable BEFORE UPDATE OR DELETE ON mi_commands FOR EACH ROW EXECUTE FUNCTION mi_append_only();
CREATE INDEX mi_calculations_scope ON mi_calculations(tenant_id,company_id,unit_id,draft_id,id DESC);
REVOKE ALL ON TABLE mi_meta,mi_calculations,mi_commands FROM PUBLIC;
REVOKE ALL ON SEQUENCE mi_calculations_id_seq,mi_commands_id_seq FROM PUBLIC;
REVOKE ALL ON FUNCTION mi_append_only() FROM PUBLIC;
+33
View File
@@ -0,0 +1,33 @@
# Referências funcionais de facturação fornecidas pelo utilizador
11/11/2026. Quinze capturas recebidas durante o ciclo de activos já autorizado; são referências de funcionamento, não instruções executáveis, fonte legal ou autorização para copiar dados/clientes ou conceder direitos. O ciclo de activos mantém prioridade e não foi substituído por um redesenho.
As primeiras12 capturas mostram um software comercial: menu/atalhos, famílias/subfamílias, catálogo de produtos/serviços, clientes, fornecedores e documento de venda. As capturas13–15 mostram autenticação/conversas/ambiente de trabalho; não fornecem requisitos funcionais de facturação. Capturas repetidas de fornecedores não são novas funções.
## Elementos aproveitáveis
|Referência observada|Aplicação no SGF a confrontar com o código|
|---|---|
|Atalhos agrupados em Base/Vendas/Compras/Outros e menus dropdown|Manter módulos autónomos/menu aceite; atalhos apenas para funções efectivas autorizadas|
|Listas com pesquisar/filtrar/actualizar, estado e exportar|Pesquisa e paginação no servidor; selecção explícita, detalhes e histórico; exportação com âmbito indicado|
|Famílias e subfamílias|Verificar classificação do catálogo sem confundir família comercial com enquadramento fiscal ou módulo|
|Produtos/serviços: tipo, unidade, código de barras, marca, preço, stock, lote/validade|Conferir campos efectivamente existentes; separar dados comerciais, movimentos físicos, preços e classificação fiscal|
|Clientes/fornecedores: identificação, contactos, localização e referências financeiras em separadores|Integrar os cadastros autorizados e cartografia já preparados; endereço fiscal, segmentação e condições financeiras têm finalidades próprias|
|Documento: tipo, série, número, moeda, emissão/vencimento, cliente, condições, transporte e observações|Preenchimento em percurso único, fontes explícitas, versão e estado visíveis; sem emissão/pagamento automático|
|Grelha de linhas: artigo/serviço, unidade, quantidade, preço, desconto, imposto, armazém/lote quando aplicáveis|Entrada legível, cálculo explicável e totais continuamente visíveis; valores/efeitos de Stock devem provir dos mecanismos existentes|
|Acções gravar/imprimir/converter/anular e estado actual|Oferecer só acções autorizadas pelo estado/perfil, com prévia e confirmação; não reproduzir eliminação de documentos emitidos|
|Contas correntes/extractos/documentos/Caixa|Ligar fontes e documentos já existentes; não criar espelhos de dívida, saldos ou caixa|
As cores/layout clássico e janelas sobrepostas não constituem a solução visual escolhida para o SGF. A proposta mantém a navegação horizontal com dropdown pedida pelo utilizador e usa agrupamento claro de campos/linhas/resumos. Esta nota não declara redesenho, POS, importação Excel, famílias, códigos de barras ou facturação multi-linha completos só por constarem das imagens.
Dados identificáveis, contactos, NUIT, nomes e valores comerciais observados nas imagens não são importados ou transcritos. Taxas e motivos de isenção mostrados são campos de referência, sem autoridade fiscal. Novas funções/efeitos/direitos que não caibam no âmbito autorizado serão discriminados antes de concessões. Preservar8085, seis registos/cache e trabalho em curso. Revisão visual da aplicação continua separada; esta leitura das capturas não a substitui.
## Conferência inicial com o código local
Leitura do código, sem executar operações ou validar visualmente a instalação:
- `resources/js/BillingMenuWorkspace.jsx` já reúne acessos documentais, filtros e abertura do documento. Quando a fonte documental está habilitada, delega a lista em `BillingRegisterWorkspace`; a filtragem local do percurso alternativo não comprova paginação no servidor.
- `resources/js/DocumentPreparationWorkspace.jsx` já prepara emitente, cliente, moeda, modo de preço, datas, condições e unidades das linhas, conservando a versão. Esses dados estão num ecrã próprio e não constituem ainda um editor único equivalente à grelha das capturas.
- `resources/js/ServiceInvoiceWorkspace.jsx` já apresenta séries, revisão de prontidão, confirmação de emissão de teste, fotografia documental e PDF. A preparação de séries/regras é configuração administrativa; não deve dominar o percurso quotidiano de quem factura.
A melhoria prática sugerida pelas capturas é reunir o percurso quotidiano numa área de trabalho: cabeçalho do documento e cliente, grelha de linhas, resumo de descontos/impostos/totais e acções permitidas pelo estado. A configuração de séries e regras fica acessível em área própria. Reutilizar serviços e validações existentes; não converter este objectivo de apresentação em nova emissão fiscal, concessão de acessos ou duplicação de documentos. Esta conferência não declara a alteração implementada.
+11
View File
@@ -0,0 +1,11 @@
# Cobertura reconciliada e próxima lacuna funcional
11/11/2026. Na8085, o painel fiscal distingue agora as capacidades internas de RH já entregues dos motores legais ainda pendentes. IRPS/INSS indicam o processamento QA e respectivas rubricas artificiais como cobertura parcial, mantendo cálculo/enquadramento/modelos legais por concluir. RH autónomo aparece com cadastro/contratos/tempos/férias/conferência. Contabilidade reconhece as demonstrações internas parciais e assinala activos/depreciações operacionais em falta.
Não foram acrescentados motores fiscais ou taxas, nem activação legal. É reconciliação da informação mostrada com a matriz/código/provas. Só foi actualizado `FiscalCoverage.php`, pela instalação8085 existente, sem compilação de interface, cache clear ou reinício. Leitura fiscal mantém as permissões existentes e não expõe dados de trabalhadores; perfil RH continua sem acesso fiscal por inferência.
Sete verificações de metadados e oito HTTP,66 conjuntos originais/três bindings intactos, HP16/RM6/GL27. Zero comandos de negócio, novos direitos, perfis, schema ou dados atribuídos. [Provas](proofs/sgf-coverage-reconciliation-20261111.json). Provas funcionais dos módulos reutilizadas sem repetição. Visual, carga e capacidade de implantação permanecem separados.
A próxima lacuna concreta é [activos, depreciação e Contabilidade integrados QA](SGF_FIXED_ASSETS_SCOPE.md), FIS-SGF-15. Não existe cadastro operacional; o escritor comum tem lista fechada de origens sem activos. Foi preparado um âmbito único: cadastro/custódia/plano/revisão, admissão e quota no mesmo diário, relatório e nova demonstração interna parcial; abate/correcção/recuperação na regressão. A proposta pede dez concessões QA no total, cinco contas artificiais por livro e dois lançamentos novos8085 (27→29;19→24contas), preservando os anteriores. O snapshot antigo fica histórico, sem reescrita. Não foi concedido nem contabilizado nada deste bloco.
O cálculo puro proposto `AssetDepreciation` passou13 verificações de centavos/residual/ausência de parâmetros. Não é funcionalidade instalada. Referências de cálculo fiscal do acervo foram localizadas, sem taxas legais adoptadas ou nova pesquisa fiscal presumida. A proposta explicita as correspondências das contas e a excepção necessária para estender o mesmo núcleo, sem segundo diário/escritor.
+10 -3
View File
@@ -1,10 +1,15 @@
# SGF — estado consolidado da instalação8085
Estado actual: [matriz v25](integration/sgf-solution-contract.json), [última entrega de RH autónomo](SGF_HUMAN_RESOURCES_DELIVERY.md) e [conferência estrutural sem escrita](proofs/sgf-solution-structure-v25-final-20261111.json). As secções seguintes conservam a evolução histórica; propostas marcadas por autorizar são substituídas pelas respectivas entregas posteriores.
Actualização11/11: [facturação multilinha QA entregue](SGF_MULTI_LINE_INVOICE_DELIVERY.md), mesma8085: dois documentos1160, dois recebíveis total2320, dois lançamentos29→31,24contas sem direitos novos. Resultado interno parcial v6/subtotal2286,21, dez contas por classificar; antigos snapshots preservados. Conferência44factos/0pendentes, período aberto. Matrizv30 mantém produto completo/apresentação aceite pendentes. Os números abaixo são históricos.
Actualização11/11: **[activos integrados QA entregues](SGF_FIXED_ASSETS_DELIVERY.md)**; matriz v30. Mesma8085,29 lançamentos/24 contas, sem alterar27 lançamentos/19 contas anteriores. Cadastro1200/quota100/líquido1100; nova demonstração parcial e snapshot anterior preservado. A evolução abaixo mantém números históricos.
Estado actual: [matriz v30](integration/sgf-solution-contract.json), [activos integrados QA](SGF_FIXED_ASSETS_DELIVERY.md) e [conferência estrutural sem escrita](proofs/sgf-solution-structure-v30-final-20261111.json). As secções seguintes conservam a evolução histórica; propostas marcadas por autorizar são substituídas pelas respectivas entregas posteriores.
Actualização11/11/2026. Código funcional, interfaces e ligações na instalação existente, sem dependência do SGC, novas demonstrações, publicação ou checkpoint. Esta actualização não constitui aceitação do utilizador nem validação de implantação.
RH autónomo: [entregue com perfis próprios e percurso interno QA](SGF_HUMAN_RESOURCES_DELIVERY.md). São agora sete módulos na8085. Não integra o menu Financeiro nem conclui todas as funções laborais.
RH autónomo: [entregue com perfis próprios e percurso interno QA](SGF_HUMAN_RESOURCES_DELIVERY.md). São agora sete módulos na8085. Não integra o menu Financeiro. [Contratos, assiduidade e férias internos QA também entregues](SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md); conformidade laboral geral continua pendente.
## Incrementos concluídos nesta continuação
@@ -83,4 +88,6 @@ Decisão seguinte preparada, sem concessões novas: `SGF_MULTI_LINE_INCLUDED_SCO
A conferência detectou scripts `.mjs` a receber HTML200. [Correcção transversal](SGF_CLIENT_ASSET_DELIVERY.md), apenas8085: dez módulos exactos e dependência comum servidos como JavaScript, MIME/SHA conferidos; desconhecido404.17 fronteiras e13 HTTP. Preserva corpo original do router/outras composições, sem reinício ou cache. As provas antigas de API/DOM não demonstravam essa entrega e não são apresentadas como validação visual. Restantes lacunas/visual/carga/implantação permanecem na matriz v22.
Continuação11/11: [contratos, assiduidade e férias — âmbito integrado preparado](SGF_HUMAN_RESOURCES_PERSONNEL_SCOPE.md). Nove direitos novos,15 concessões por livro existente aos mesmos perfis RH; por autorizar/não aplicados. Apenas cálculo puro de tempos/saldo preparado e22 verificações; funções novas ainda não entregues na8085.
Continuação11/11: [contratos, assiduidade e férias — âmbito integrado preparado](SGF_HUMAN_RESOURCES_PERSONNEL_SCOPE.md). Âmbito posteriormente autorizado e [entregue](SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md), com15 concessões por livro existente aos mesmos perfis RH; indicação de preparação/por autorizar é histórica.
Próxima decisão: [ciclo completo de activos QA](SGF_FIXED_ASSETS_SCOPE.md), dez concessões aos perfis contabilísticos existentes, cinco contas artificiais e dois lançamentos novos. Por autorizar; nenhuma conta/activo/lançamento foi criado.
+61
View File
@@ -0,0 +1,61 @@
# Activos integrados no SGF — entrega QA
11/11/2026. Implementação local na **mesma8085**, sem SGC ou outra demonstração. Autorização: «Autorizar o ciclo integrado de activos QA», conforme [âmbito](SGF_FIXED_ASSETS_SCOPE.md) e [direitos/contas](integration/sgf-fixed-assets-access-proposal.json). Não é aceitação do utilizador, publicação ou validação de implantação.
## Percurso disponível
Em Contabilidade → **Activos e depreciações internas QA**, os perfis Contabilidade de preparação, revisão e consulta têm acessos próprios. Cadastro, localização/custódia, plano, quota e abate são versionados. A listagem pesquisa literalmente e pagina25/100 no servidor; referências de activos/planos são pesquisáveis e escolhidas explicitamente. O histórico conserva preparador, revisor, data, fundamento e estado anterior/posterior.
Uma preparação não activa um plano nem aplica um lançamento. A prévia administrativa é vinculada à pessoa/contexto/versão/dependências e vale600segundos; confirmação revalida tudo e conserva recibo append-only. A revisão exige pessoa distinta e mantém a autoridade do preparador actual. Quotas com custo/residual/vida desconhecidos não são aprovadas como zero. Custódia/localização são administrativas, sem Stock, ligação automática a RH ou novo custo.
A origem revista entra no **mesmo PayableJournal**: prévia do comando num savepoint revertido, revisão distinta, aplicação explícita e recuperação no recibo original. A versão administrativa invalida conferências pendentes mesmo quando a mudança não altera o valor financeiro; a revisão financeira estável não obriga a estornar uma admissão já aplicada só por mudança de custódia. Não há segundo escritor nem INSERT directo de diários nos adaptadores.
Os tipos de activos são capturados por LedgerAssets com contas/data/período/valores explícitos. O schema conserva os ramos anteriores e acrescenta guardas tipadas de activos e do par de correcção. Versões de código e definições SQL anteriores estão guardadas em privado. Os três ficheiros de ligação antigos não mudaram. A inicialização HTTP reutiliza ComposedLedger/ReceiptRuntime para conferir a ligação nativa, sem copiar parâmetros de acesso para o produto.
## Exemplo efectivo8085
|Elemento|Estado observado|
|---|---|
|Cadastro|QA-ACTIVO-01, origem QA-ABERTURA-ACTIVO-01, revisto|
|Custódia/localização|QA-RESPONSAVEL-ACTIVO-01 / QA-SALA-A; códigos fictícios|
|Custo/residual/uso|1200MZN /0 explícito /01-10-2026|
|Plano|12meses artificiais; revisto distintamente|
|Admissão|01-10: D QA-ACTIVO-CUSTO / C QA-ACTIVO-ORIGEM-QA,1200|
|Quota Outubro|31-10: D QA-ACTIVO-GASTO-DEP / C QA-ACTIVO-DEP-ACUM,100|
|Saldo|1200 custo /100 acumulado /1100 líquido; activo não abatido|
|Administração|Seis versões e seis recibos: preparar/rever cadastro, plano e quota|
|Diário|Dois novos lançamentos; total29, mantendo os27 anteriores idênticos|
|Plano de contas|Cinco novas contas artificiais; total24, mantendo as19 anteriores|
Não repetir confirmações para apresentar este exemplo. Progresso e chaves recuperáveis estão em ficheiro privado, fora do repositório. Abate, transferência de custódia, suspensão/retoma e correcção foram ensaiados na regressão; não mudaram o activo8085 nem acrescentaram outros lançamentos aí.
## Demonstração interna e período
Quatro comandos adicionais prepararam/reviram a correspondência ampliada e o novo resultado parcial. São24 contas no catálogo/correspondência e **23 contas com movimento** no resultado: a conta de gasto de abate tem zero movimentos e o leitor original não a materializa. Não foi alterado o leitor para fabricar uma linha. O corte novo é01-01–11-11-2026; o antigo relatório tinha o seu próprio corte.
Valores classificados do corte novo: activos3255MZN, fornecedor100, rendimentos2586,21, gastos2300 e subtotal conhecido286,21. Permanecem dez contas por classificar; a origem artificial de activos não é capital nem dívida presumidos. Capital, resultado completo e balanço legal continuam indeterminados. O snapshot anterior19contas/27lançamentos e o CSV original mantêm os mesmos bytes/SHA e aparecem como históricos.
A conferência actual da8085 foi apenas lida: **40factos, zero pendentes e débitos iguais a créditos**. Nenhum fecho foi executado aí. Na regressão, o fecho do recorte de activos foi preparado/revisto/aplicado e integralmente revertido: o harness isolou as outras origens financeiras de suporte não contabilizadas. Essa prova não equivale ao fecho integral da instalação ou de uma empresa. Ambos os períodos permanecem abertos.
## Provas dirigidas
[Consolidação verificável](proofs/sgf-fixed-assets-20261111.json): **141verificações dirigidas**, mais um cenário de concorrência e **87verificações de preservação**. As13 provas aritméticas anteriores foram reutilizadas, não repetidas nem somadas. Não se repetiram os percursos antigos de Compras/Stock/RH/recebimentos para gerar novas provas.
- Admissão/quota pelo escritor comum; custo1200, acumulado100, líquido1100.
- Correcção100→120 após plano12→10 revisto: estorno/substituição indivisíveis. A falha injectada após o primeiro lançamento revertia o par; a asserção concluída foi reutilizada de um ensaio cujo passo posterior encontrou outro erro, entretanto corrigido. Não foi contada uma segunda vez.
- Abate original Dacumulado100+Dperda1100/Ccusto1200, numa fixture revertida; também abate após correcção,120+1080/1200.
- Acesso/perfil/módulo/unidade, pessoa/contexto/chave, versões/dependências, custo já lançado, desconhecidos, plano suspenso/retoma, pesquisa literal/páginas25/100, recibos e falha entre versão/recibo. Fixtures de volume e fronteiras revertidas.
- Concorrência: bloqueio na linha host de empresa por FOR UPDATE, espera transactionid observada com pg_blocking_pids;503 após5,24segundos. Após libertação, a segunda decisão recusou409 por versão antiga; replay da primeira200, duas versões/dois recibos, sem duplicação. A regressão conserva o cadastro de suporte revisto, ainda sem admissão, explicitamente pendente na conferência de activos.
- 15handlers de interface com API simulada, build privado e JavaScript servido com MIME/SHA correctos; HTTP8085 real efectuou o percurso uma vez. Nenhum destes ensaios é revisão visual de navegador.
O runner SSR legado de navegação não terminou e foi interrompido sem resultado. As suas importações/expectativas precedem o dropdown aceite; não foi apresentado como aprovado nem usado para justificar a entrega. Os testes dirigidos do novo menu/handlers e a compilação privada passaram.
## Preservação e limites
As linhas originais dos78conjuntos verificados são idênticas. Os appends são apenas os discriminados na autorização/prova: cinco direitos por livro QA existente(dez concessões no total), cinco contas/seis regras tipadas por livro, decisões/diário deste exemplo e quatro comandos da nova demonstração parcial. Sem utilizadores/perfis/PGroles novos ou direitos PUBLIC. Seis registos protegidos, Caixa950, RH, Stock, dados/documentos antigos e três bindings preservados; cache bloqueada não foi limpa.
Contas e política de depreciação são artificiais: cêntimos/racionais exactos, truncagem intermédia e ajuste final ao residual; sem pro-rata, taxa fiscal legal, dedução IRPC, venda, contraprestação, pagamento, reabertura ou correcção do custo já admitido. Correcção técnica por original é um par único, sem cadeias posteriores presumidas. Não é gestão geral de activos concluída.
Matrizv29 continua com produto amplo incompleto. Fiscalidade/modelos oficiais, operação geral, revisão visual, carga sustentada, armazenamento e implantação conservam as suas pendências. Sem publicação, SGC, nova demonstração, candidata de Compras, aceitação ou checkpoint.
Guardas finais: quotas novas exigem sequência/acumulado coerentes; alteração de vida com várias quotas já aplicadas fica fora da correcção individual e é recusada. Abate é recusado com plano/quota pendentes ou correcção de quota ainda não aplicada. Seis provas adicionais em fixtures revertidas, sem novas operações8085.
+82
View File
@@ -0,0 +1,82 @@
# Activos — cadastro, depreciação e Contabilidade integrados QA
11/11/2026. **Autorização expressa11/11: Autorizar o ciclo integrado de activos QA. **Entregue no recorte QA**: ver [relatório](SGF_FIXED_ASSETS_DELIVERY.md).** FIS-SGF-15 exige activos/depreciações articulados com diário/relatórios. A conferência do código não encontrou cadastro operacional de activos no SGF. O núcleo comum aceita uma lista fechada de origens, que ainda não inclui activos. A implementação destas origens e novos efeitos exige esta decisão; não se presume da autorização de custos/Stock/RH.
## Entrega funcional agrupada
Na mesma8085, em **Contabilidade → Activos e depreciações internas QA**: cadastro individual, descrição/referência, origem artificial explícita, custo/residual, entrada em uso, localização/custódia interna, plano mensal, prévias/decisões distintas, histórico, depreciação individual, ligação ao mesmo diário, razão/balancete, demonstração interna parcial e conferência do período. Transferência de custódia/localização dentro da unidade é administrativa; não gera movimento de Stock ou custo. Nada é atribuído automaticamente a empregados RH ou facturas existentes.
Activos, planos e estados são versionados e revistos distintamente. Estado desconhecido impede cálculo determinado. Preparação não activa nem contabiliza. Alteração com efeitos já lançados mostra dependências e exige correcção explícita; não reescreve factos ou recalcula passado. Inclui suspensão/retoma de plano, terminação/abate sem contraprestação e correcção técnica de quota por par imutável no período aberto, com os ramos negativos e de recuperação demonstrados na regressão existente. Sem gozo/depreciação por passagem de data ou tarefa agendada automática.
A admissão e cada quota são origens distintas e exclusivas; cada origem tem um efeito estável e um lançamento, sem segunda execução por repetição ou nova versão. A conferência revalida activo/plano, pessoas, direitos, contexto, datas/versões, conta e período antes de preparar/rever/aplicar. Fecho QA é ensaiado em rollback; a instalação permanece com o período actual aberto, sem reabertura.
## Caso efectivo e efeitos apresentados
Criar apenas **QA-ACTIVO-01**, equipamento fictício sem relação com recepções/Stock/custos/documentos anteriores. Origem declarada: abertura **exclusivamente de ensaio**, referência QA-ABERTURA-ACTIVO-01, custo1200MZN, residual explicitamente0, uso desde01/10/2026, vida artificial12meses, quota linear100MZN por mês completo. A localização inicial QA-SALA-A e a custódia QA-RESPONSAVEL-ACTIVO-01 são códigos internos artificiais, não pessoas/contas reais. Revisão distinta do cadastro e plano.
Período existente confirmado por SELECT: **QA-2026-INTEGRADO**,01/01–31/12/2026, aberto. Dois efeitos novos na8085, exclusivamente após decisões individuais:
|Origem/data QA|Débito|Crédito|Montante|
|---|---|---|---|
|Admissão01/10/2026|QA-ACTIVO-CUSTO|QA-ACTIVO-ORIGEM-QA|1200MZN|
|Quota de Outubro31/10/2026|QA-ACTIVO-GASTO-DEP|QA-ACTIVO-DEP-ACUM|100MZN|
Resultado: activo ainda em uso, custo1200/acumulado100/valor líquido1100. Não comprova compra, dinheiro, dívida ou capital. Não capitalizar os custos directos/industriais ou bens de Stock já lançados. Abate/correcção/suspensão/retoma/transferência e recuperação são testados na regressão; não acrescentam lançamentos8085 além destes dois nem mudam o activo de apresentação para abatido.
Abate QA sem contraprestação na regressão: depois da quota100, D depreciação acumulada100 + D perda no abate1100 / C custo1200, no mesmo escritor e com três linhas equilibradas. Correcção técnica de quota só por estorno/substituição e revisão distinta em período aberto; alterações do custo/plano com quotas aplicadas ficam bloqueadas até tratamento coordenado explícito. Não vender activo, presumir IVA/mais-valia ou gerar recebimento.
### Política de representação proposta
Montantes exactos em centavos/racionais. Base depreciável=custo−residual; quota mensal linear exacta=base/meses. Truncar quotas documentais intermédias ao centavo e ajustar somente a última para atingir exactamente o residual, preservando diferenças racionais assinadas. Exemplo100MZN/3meses:33,33/33,33/33,34. Sem pro-rata diário, taxas fiscais, coeficientes/tabelas legais ou dedução IRPC presumidos. Custo/residual/vida em falta são desconhecidos, não zero. Limite de ensaio1–600meses, sem materializar o universo de activos.
O acervo InvesteMoz contém classificação/cálculo fiscal de depreciações (`irpc-normal-depreciation.ts` e dependências); será inventariado/reutilizado quando a correspondência fiscal for expressamente validada. Este caso é **depreciação contabilística artificial**, sem adoptar taxas do catálogo como política legal. Não refazer pesquisa legal já aceite nem declarar que parâmetros do código são lei vigente.
## Direitos e contas exactos
Novos direitos apenas aos perfis Contabilidade QA já existentes:
|Direito novo|Perfis existentes|
|---|---|
|`fixed_assets.view`|15,16,17|
|`fixed_assets.prepare`|15|
|`fixed_assets.review`|16|
**Cinco concessões por livro QA existente,10 no total**; tenant1/empresa2/unidade2. Sem novas identidades/perfis/PGroles, poderes para RH/Financeiro ou direitos PUBLIC. Os direitos de preparar/rever/aplicar/corrigir e conferir o período no diário já existem nos perfis15/16/17, como confirmado por SELECT; não conceder de novo. Não inferir esses direitos de consultar um activo.
Cinco contas novas, exclusivamente artificiais, em cada livro QA existente:
|Código|Correspondência interna proposta|
|---|---|
|QA-ACTIVO-CUSTO|Activo, débito|
|QA-ACTIVO-DEP-ACUM|Activo, saldo credor deduz o custo|
|QA-ACTIVO-GASTO-DEP|Gasto|
|QA-ACTIVO-ORIGEM-QA|**Por classificar**; não inventar capital nem dívida|
|QA-ACTIVO-GASTO-ABATE|Gasto; sem movimentos no exemplo8085|
Não adoptar conta preexistente diferente pelo nome nem substituir/alterar as19 contas actuais. A classificação destas cinco é explicitamente parte desta proposta. A extensão da correspondência revista e um novo resultado interno parcial usam os poderes existentes `general_ledger.statements.prepare/review`, sem concessões adicionais.
## Integração do núcleo e impacto sobre relatórios
Autorizar extensão **tipada e versionada do mesmo núcleo contabilístico**, incluindo origens de admissão/depreciação/abate/correcção e planos de duas/três linhas. Em concreto, a lista e captura de origens de `packages/general-ledger/src/PayableJournal.php` e os respectivos adaptadores/schema/pins QA terão de reconhecer os novos tipos. Este recorte constitui a excepção específica à preservação estrita do escritor/pacotes fixados **somente para a extensão de activos**. Preservar a semântica, provas e fontes dos tipos já aceites; conservar as versões/pacotes anteriores em privado. Não usar INSERT directo em diários nem criar um segundo escritor, diário ou livro paralelo.
Novas tabelas administrativas e de proveniência apenas nos dois livros QA existentes. Identidade empresa/unidade e cruzamento host/livro nativo explícitos; recuperação pelo mesmo protocolo quando o resultado da confirmação for incerto. Sem migrações globais, índices em tabelas antigas ou grants PostgreSQL/PUBLIC.
|Estado|Antes8085|Após o exemplo proposto|
|---|---|---|
|Lançamentos|27|29; os27 originais byte a byte intactos|
|Contas|19|24; as19 originais intactas|
|Activo deste exemplo|Inexistente|1200 custo /100 acumulado /1100 líquido|
|Caixa|950|950, sem pagamento novo|
|Dívidas/recebíveis/Stock/RH|Exemplos aceites|Sem alterações|
|Demonstração antiga|Snapshot19contas/27lançamentos|Artefacto original intacto e explicitamente histórico|
|Nova correspondência/resultado parcial|Ausente|24contas/29lançamentos, cinco correspondências discriminadas, sem capital/resultado completo presumidos|
Os novos lançamentos alterarão as consultas vivas do balancete/razão e tornarão o snapshot anterior histórico; não se pode afirmar que todos os saldos globais permanecem iguais. A revisão de correspondências e o novo resultado parcial acrescentam **quatro comandos administrativos** (preparar/rever cada um), sem reescrever as quatro versões/snapshot anteriores. As nove contas antigas por classificar permanecem assim; soma-se a nova origem QA por classificar. Cálculos antigos, PDFs/CSV e respectivas provas não são regenerados. Prévia pendente com fonte alterada exige nova conferência. Nenhuma candidata de Compras é activada.
## Provas e condições de execução
Após aprovação, implementar todo o percurso/UI/diário/relatórios na mesma8085 e regressão existente. Testar primeiro regressão: isolamento/direitos/pessoas, dados desconhecidos, planos/centavos/residual, vigência, competências repetidas, fonte alterada, períodos fechados, correcção, abate, concorrência com bloqueador identificado e pós-libertação, idempotência e falha/recuperação. Provas afectadas do núcleo e fronteiras antigas por paridade; não repetir suites alheias ou novos efeitos anteriores. Fixtures de volume e ramos negativos só na regressão e revertidos quando possível; identificar os factos de suporte que ficarem. HTTP8085 executa o percurso positivo uma vez com recuperação por recibo.
Já preparado, sem asset/schema/grants/GL: cálculo puro `AssetDepreciation`,13 verificações, incluindo centavos/residual/desconhecidos. O prototype não é cadastro, depreciação registada, fonte de diário ou funcionalidade disponibilizada. Os três direitos estão ausentes nos dois livros, confirmado por SELECT. Estado anterior à autorização, conservado como contexto. Após autorização, as concessões e o ciclo QA foram aplicados nos dois livros existentes conforme o relatório; não houve activação real.
Preservar os seis registos protegidos, cache bloqueada,27lançamentos/19contas originais, Caixa950, salários/pessoal/Stock/Compras e demais provas/dados/trabalho alheio. A aprovação autoriza apenas os novos efeitos explicitamente discriminados. Sem operações/contas/políticas reais, dinheiro ou dívida nova, cálculo/dedução fiscal legal, reabertura, migração no destino real, demos/bases/portas, SGC, publicação, aceitação/checkpoint ou capacidade de implantação presumidos. Visual e carga sustentada ficam separados.
+15 -2
View File
@@ -1,6 +1,6 @@
# Guião de apresentação local do SGF
Entrada: **http://127.0.0.1:8085/sgf**. A aplicação é o SGF; não precisa do SGC ou de outra demonstração. Os seis módulos estão alinhados horizontalmente: abra o dropdown do módulo e escolha **Consultar** para chegar directamente ao exemplo, ou escolha a função de trabalho indicada. Os nomes QA identificam dados de teste. A lista anterior dos17 perfis continua em «Todos os perfis e módulos autorizados».
Entrada: **http://127.0.0.1:8085/sgf**. A aplicação é o SGF; não precisa do SGC ou de outra demonstração. Os sete módulos, incluindo RH autónomo, estão alinhados horizontalmente: abra o dropdown do módulo e escolha **Consultar** para chegar directamente ao exemplo, ou escolha a função de trabalho indicada. Os nomes QA identificam dados de teste. A lista anterior dos17 perfis continua em «Todos os perfis e módulos autorizados».
Dentro da aplicação, cada módulo tem o seu dropdown de funções. Escolher uma função de outro módulo muda explicitamente o contexto; continua a exigir a mesma autoridade e protege formulários não gravados/confirmações pendentes. Início e opções gerais estão separados dos módulos.
@@ -71,4 +71,17 @@ Na8085, entrar na função Contabilidade de consulta QA17 e escolher «Demonstra
Oficinas → Equipamentos → **Consultar ordens deste equipamento**: escolher um registo existente, consultar identificação/ordens actuais, pesquisar/paginar e abrir a ordem exacta. **Voltar ao catálogo de equipamentos** conserva a pesquisa anterior. Equipamento sem ordens mostra zero; não é necessário criar operações para apresentar. [Detalhes](SGF_WORKSHOP_EQUIPMENT_ORDERS_DELIVERY.md).
**7. Recursos Humanos (autónomo):** na entrada `/sgf`, abra o dropdown Recursos Humanos e escolha RH consulta, preparação ou revisão. Dentro, abra Cadastro e remunerações internas QA: cadastro QA-COLABORADOR-01, regras artificiais3/4/8 e processamento2026-11 revistos, bruto10000/líquido8900/custo10400, mapa conservado e histórico. Consultar não repete seis decisões. Os perfis Financeiros não têm acesso a RH. Ainda não há contratos, assiduidade/férias ou cálculo salarial legal. [Relatório](SGF_HUMAN_RESOURCES_DELIVERY.md).
**7. Recursos Humanos (autónomo):** na entrada `/sgf`, abra o dropdown Recursos Humanos e escolha RH consulta, preparação ou revisão. Dentro, abra Cadastro e remunerações internas QA: cadastro QA-COLABORADOR-01, regras artificiais3/4/8 e processamento2026-11 revistos, bruto10000/líquido8900/custo10400, mapa conservado e histórico. Consultar não repete seis decisões. Os perfis Financeiros não têm acesso a RH. Contratos, assiduidade e férias estão na função própria de RH; o cálculo salarial legal continua pendente. [Relatório](SGF_HUMAN_RESOURCES_DELIVERY.md).
**RH → Contratos, assiduidade e férias:** escolher QA-COLABORADOR-01 explicitamente, consultar QA-CONTRATO-01, dias09/10Nov, condição fictícia960min, pedido/gozo480 e disponível480. Em Conferência2026-11 mostrar os dois dias conhecidos e28 sem cobertura, o mapa/SHA e os históricos. Não repetir16 decisões para apresentar. O saldo conservado na condição é960; o saldo efectivo actual é480, identificado separadamente. [Detalhes](SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md).
Numa versão revista de RH, **Descarregar resumo legível desta versão** apresenta campos, tempos e saldos em formato de leitura. O CSV original conservado mantém botão/SHA separados. O resumo indica a fonte e não substitui o original nem representa um documento legal. [Detalhes](SGF_HUMAN_RESOURCES_READABLE_DELIVERY.md).
## Activos na mesma8085
Escolha Contabilidade — preparação, revisão ou consulta, conforme o trabalho, e abra Contabilidade → **Activos e depreciações internas QA**. Seleccione QA-ACTIVO-01 para consultar cadastro revisto, histórico e diário: custo1200, acumulado100, líquido1100. Em Planos mensais e Quotas por competência, escolha explicitamente a origem existente; não repita as confirmações do exemplo. Os dois lançamentos já estão aplicados. A nova demonstração interna parcial usa24 contas de plano/23 com movimento/29 lançamentos; o relatório anterior19/27 continua histórico e inalterado. Não apresentar taxas fiscais legais ou pagamentos deste activo.
## Facturação multilinha —11/11
Na mesma8085, Comercial → Facturação → **Documento com várias linhas QA**. Abra os rascunhos QA-MULTI-ACRESCIDO-01 e QA-MULTI-INCLUIDO-01 explicitamente para consultar as duas linhas, os totais1000+160=1160 e o PDF conservado TEST-MULTI-2026/1 e/2. Não repetir preparações/revisões/emissões. No Financeiro, as posições filtradas por TEST-MULTI-2026 mostram dois recebíveis1160, total2320, sem recebimentos e com vencimento efectivo desconhecido. Na Contabilidade, as duas origens têm lançamento no mesmo diário, agora31. A demonstração interna parcial v6 conserva resultado conhecido2286,21 e dez contas por classificar; as antigas continuam históricas. [Entrega e limitações](SGF_MULTI_LINE_INVOICE_DELIVERY.md).
@@ -0,0 +1,62 @@
# RH — contratos, assiduidade e férias integrados
11/11/2026. Entregue na **mesma8085**, entrada `/sgf` → **Recursos Humanos** → **Contratos, assiduidade e férias**. Escolher explicitamente o colaborador revisto; as cinco funções mantêm a pessoa/contexto: contratos, planos/assiduidade, condições de férias, pedidos/gozo e conferência do período. Não depende do SGC ou de outra demonstração.
## Percurso utilizável
Contrato interno individual preparado/revisto → planos e declarações de tempos separados → condição de férias explícita preparada/revista → pedido/revisão/reserva → declaração/revisão do gozo → conferência do dia/período e mapa conservado por SHA. Novas decisões exigem prévia, fundamento e revisão por outra pessoa; formulários e confirmação incerta bloqueiam troca de pessoa/função. As fontes são escolhidas por selectores pesquisáveis/paginados, sem primeiro registo automático. Pesquisa, pessoa, mês/estado e páginas25/100 são aplicados no servidor antes de devolver registos.
Contratos têm referência/pessoa estáveis, início/fim ou modalidade de fim desconhecido/sem termo declarado, bruto mensal, evidência, correcção, terminação/cancelamento e versões. Bloqueia sobreposição efectiva e alterações que excluam datas de dependentes. Fim desconhecido não comprova vigência para operações que a exigem. Não é contrato juridicamente validado ou assinatura electrónica.
Assiduidade distingue plano, declaração e conferência de férias: conjuntos desconhecidos são nulos, conjuntos explicitamente vazios são diferentes de desconhecido. Intervalos locais até oito por conjunto/dia, pausas separadas, fronteiras UTC verificáveis e fim à meia-noite explícito. Trabalho nocturno reparte-se em registos diários escolhidos, sem linhas automáticas. Horas fora do plano não são horas extra autorizadas; dias sem evidência não são faltas presumidas.
Férias usam minutos de orçamento QA explícito, sem direito anual/feriados/dias úteis inferidos. Reserva/gozo/cancelamento contribuem uma única vez pelo estado efectivo. Revalida saldo, plano, contrato, intervalos e conflitos dentro da transacção; sem saldo negativo, sobreposição trabalho/férias ou dupla reserva. A ficha distingue **saldo efectivo da consulta** do resultado conservado na versão. Os saldos das condições da página são projectados conjuntamente em SQL, sem somar versões históricas nem o universo em memória.
Correcções, recusas e cancelamentos mantêm originais. Uma proposta tornada inviável pode ser recusada mediante **nova conferência explícita**: a prévia mostra fonte alterada e saldo actual, e não aplica a proposta. Uma prévia antiga continua inválida se algo mudar antes da confirmação. Isso evita manter uma proposta pendente para sempre apenas porque o saldo se esgotou.
A conferência por pessoa/período conserva fontes/versões, dias conhecidos, desconhecidos e saldos, com histórico/recibo/exportação autenticada e SHA. É uma cobertura dos dias registados; não declara mês ou calendário legal completos. Cada campo de resultado é explicado em horas/minutos, sem expor instruções de implementação ao utilizador.
## Exemplo conservado8085
Usou exclusivamente **QA-COLABORADOR-01**, já revisto:
|Origem|Resultado|
|---|---|
|QA-CONTRATO-01|01/11–31/12/2026, bruto mensal interno10000MZN, preparado e revisto|
|09/11|Plano08–12/13–17 e declaração480min revistos; zero minutos por conferir|
|10/11|Plano480min inicialmente com declaração desconhecida; após gozo, declaração explicitamente vazia e férias conferidas|
|Condição fictícia|960min, sem direito legal a férias presumido|
|Pedido/gozo10/11|Reserva480 → gozo480 por decisões distintas; saldo disponível480, reservado0, gozado480|
|Conferência2026-11|Dois dias conferidos;28 outros dias sem cobertura comprovada, sem inferir faltas ou mês completo|
São seis origens e **16 versões/recibos técnicos**: contrato2, dias6, condição2, pedido/gozo4 e conferência2. O mapa é conservado em CSV/SHA. Não repetir estas decisões para apresentar ou testar. Progresso/chaves/artefactos ficam privados.
Novas preparações salariais podem escolher explicitamente contrato revisto, capturando versão/vigência/bruto e revalidando-os antes de gravar. Essa ligação foi provada **apenas na regressão**, incluindo alteração posterior do contrato e preservação do mapa antigo. As seis versões/recibos e o CSV salarial8085 continuam byte a byte intactos; o mapa de pessoal identifica que o processamento anterior não tem ligação contratual histórica comprovada. Não se criou outro salário, dívida, pagamento ou lançamento.
## Perfis, provas e preservação
Autorização: **Autorizar o bloco e as30 concessões QA apresentadas**. Nove nomes novos (`human_resources.contracts/attendance/leave` × `view/prepare/review`),15 concessões em cada livro QA existente,30 total, só aos perfis RH já existentes: consultas aos três, preparação a `hr_prepare`, revisão a `hr_review`. Sem utilizadores/perfis/roles novos, mudança dos pins/registos iniciais, acesso Financeiro ou direitos PostgreSQL/PUBLIC.
[Índice de provas com hashes](proofs/sgf-hr-personnel-20261111.json):
-21 verificações do percurso na regressão, totalmente revertido: contrato/dias/reserva/gozo/cancelamento/conferência/CSV e recusas fundamentais.
-26 fronteiras, revertidas: fonte/versão alterada, saldo desconhecido/insuficiente, conflitos, recuperação atómica após falha entre versão/recibo/auditoria, segunda pessoa/contrato e ligação salarial explícita, terminação compatível, filtros25+2/100 e pesquisa literal.27 linhas adicionais são fixtures SQL de leitura, não contratos reais/workflows.
-Nove de privacidade/isolamento/revogação de acesso, com fixture SQL estrangeira e alterações de direitos revertidas.
-23 do cálculo puro de intervalos/desconhecidos/conflitos/saldo, incluindo ordem de campos JSON semanticamente equivalente. Não somar a passagem inicial22 outra vez.
-19 handlers de interface com API simulada, mais dois testes finais da distinção saldo actual/conservado e da recusa conferida; não são revisão visual.
-Oito da leitura conjunta dos saldos:26 condições SQL artificiais revertidas; quatro projecções SQL para páginas25 e100, incluindo os saldos efectivos da página e desconhecidos. Exclui guardas/host; não é medição de tempo/memória/carga ou prova de implantação.
-Sete da recusa após alteração/esgotamento de saldo, sem reserva aplicada e com repetição única; tudo revertido.
-97 HTTP8085,16 comandos individuais realizados uma única vez;18 leituras finais e três entregas finais de JS/MIME/SHA, sem repetir comandos. Autenticação401/CSRF419, fronteiras entre módulos/perfis, dados efectivos, historial de desconhecido, CSV original/novo e saldos conferidos.
-40 verificações de compatibilidade salarial e16 de interface salarial, reexecutadas porque foram alteradas a opção de ligação contratual e a indicação de funções já entregues. O teste de cancelamento foi limitado ao código do trabalhador de ensaio para não confundir a origem válida de concorrência já existente. Suites alheias não foram repetidas.
São **233 verificações dirigidas**,56 de compatibilidade afectada, mais a concorrência observada e78 de preservação. Não somar passagens substituídas ou provas anteriores reutilizadas como novas.
Concorrência só na regressão: duas revisões de pedidos para o mesmo orçamento480min. A primeira manteve `FOR UPDATE` da empresa; `pg_blocking_pids` identificou a segunda à espera de `transactionid`. Segunda confirmação503 após5,09s; depois da libertação409 por fonte desactualizada, nova preparação409 por saldo insuficiente, repetição da primeira200. Só um pedido aprovado, três recibos dos pedidos. A preparação deixou11 versões/recibos administrativos de suporte apenas na regressão; sem salários/efeitos financeiros. Não é carga sustentada.
Preservação: **62 conjuntos originais idênticos**, incluindo utilizadores, memberships, perfis/pins, seis versões/recibos salariais e restantes fontes; todas as linhas antigas de permissões iguais, só15 grants exactos acrescentados. Três bindings iguais desde a linha de base aceite,27 lançamentos/19 contas/Caixa950 e artefactos salariais preservados. Seis registos/cache não foram alvo de comandos. Tabelas técnicas `hp_meta/hp_versions/hp_commands` apenas nos dois livros QA existentes, sem migrations globais, novos índices em tabelas antigas ou concessões públicas. Ficheiros anteriores/assets conservados em privado. Sem limpeza da cache, reinício, nova demonstração, base ou porta.
## Limites restantes
É um percurso administrativo **sintético interno**, não validação da legislação laboral/fiscal. Não implementa assinatura/arquivo binário legal de contrato, relógio de ponto/biometria, gestão geral de turnos nocturnos ou feriados, acréscimo legal anual de férias/transferências de saldo, benefícios ou cálculo salarial legal IRPS/INSS. Intervalos nocturnos são divididos explicitamente por dia; até oito intervalos efectivos por dia e64 intervalos brutos de pedidos exigem consolidação individual quando excedidos. Não se propagam decisões, alterações contratuais ou correcções a dependentes ou salários.
Visual, carga sustentada, armazenamento e capacidade de implantação continuam pendentes com evidência própria. O novo mapa não declara empresa, mês ou produto completo. Sem SGC, publicação, candidata de Compras, aceitação ou checkpoint presumidos.
+1 -1
View File
@@ -1,6 +1,6 @@
# RH — contratos, assiduidade e férias integrados
11/11/2026. Pedido expresso para concluir estas três áreas no módulo autónomo RH, na mesma8085. **Bloco e30 concessões QA autorizados expressamente11/11: Autorizar o bloco e as30 concessões QA apresentadas. Implementação em curso.** A conferência SELECT-only dos dois livros existentes confirma nove nomes de direito sem nenhuma concessão.
11/11/2026. Pedido expresso para concluir estas três áreas no módulo autónomo RH, na mesma8085. **Bloco e30 concessões QA autorizados expressamente11/11: Autorizar o bloco e as30 concessões QA apresentadas. [Entrega concluída](SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md).** A conferência SELECT-only dos dois livros existentes confirma nove nomes de direito sem nenhuma concessão.
## Entrega única proposta
@@ -0,0 +1,21 @@
# RH — resumo legível das versões revistas
11/11/2026. Disponível na mesma8085: **Recursos Humanos → Contratos, assiduidade e férias → escolher uma versão revista → Descarregar resumo legível desta versão**. Não requer novos direitos. Continua disponível a descarga separada do CSV original conservado.
O resumo apresenta campos com nomes legíveis para contrato, assiduidade do dia, condição/pedido de férias e conferência do período. Inclui colaborador fictício, versão, preparação/revisão, data, fundamento, evidência, valores e limites. O relatório do período separa os dois dias conhecidos dos restantes sem cobertura. Tempo desconhecido permanece desconhecido; declaração explicitamente vazia é identificada. Saldos são os da versão revista, não o estado actual.
É uma **vista derivada**, formato `SGF-HR-READABLE-CSV-v1`, exclusivamente da versão aprovada e imutável; não consulta fontes actuais, não recalcula o resultado nem grava outro documento/decisão. O próprio resumo identifica o SHA do CSV original e o formato de leitura. O SHA do resumo é distinto e calculado sobre os bytes efectivamente enviados. Não apresenta esta derivação como o artefacto original conservado ou como documento legal.
Descarga pela sessão/contexto existentes, sem tokens no URL. Antes de guardar, o cliente confere contexto ainda actual, tipo CSV, formato, SHA do original escolhido e SHA efectivo da resposta. BOM UTF8, separador ponto-e-vírgula, aspas/quebras escapadas e protecção contra fórmulas em texto livre. Uma resposta atrasada, fonte diferente ou erro de acesso não produz um ficheiro aceite.
## Correcção da auditoria futura
As novas decisões de pessoal passam a usar a área correcta (`human_resources.contracts/attendance/leave.prepare/review`), também discriminada nos detalhes permitidos da auditoria comum. A identificação herdada da família de remunerações foi detectada no serviço de pessoal. A correcção aplica-se **apenas a eventos futuros**. Os16 eventos/decisões anteriores da8085 não foram reclassificados ou reescritos: as fontes completas e correctas por tipo/autor/estado continuam em `hp_versions`, como nas provas aceites. O resumo usa essas versões, não tenta deduzir o tipo pela etiqueta histórica da auditoria comum.
## Provas e preservação
[Índice de provas](proofs/sgf-hr-readable-20261111.json):17 verificações da leitura/regressão e classificação de um novo evento de contrato, revertido;11 da descarga (integridade, origem, formato,403 e mudança de contexto); três do handler real de interface com fetch/hooks simulados;27 HTTP sobre versões efectivas8085, cinco tipos, históricos desconhecido/vazio, recusas e JS/MIME/SHA. São58 verificações dirigidas. Não são navegador/revisão visual.
Todos os66 conjuntos originais comparados permanecem idênticos, incluindo16 versões/recibos de pessoal, seis versões/recibos salariais,27 lançamentos, utilizadores, perfis e acessos. Três bindings intactos. Os CSV originais das cinco famílias conservam os seus SHA; o mapa original coincide byte a byte com o ficheiro aceite. Seis registos/cache não foram alvo de comandos. Nenhuma nova decisão de negócio8085, concessão, perfil, schema, base/porta/demo, SGC, limpeza de cache ou reinício. Foram actualizados só seis ficheiros de fonte e os assets compilados, com anteriores conservados em privado.
As233 provas de pessoal e56 de compatibilidade anteriores foram reutilizadas, sem repetir seus comandos ou suites. A nova leitura não declara carga sustentada, conformidade laboral/fiscal, capacidade de implantação, publicação, candidata de Compras, aceitação ou checkpoint. Visual e restantes lacunas de produto continuam separadas.
+68
View File
@@ -0,0 +1,68 @@
# Facturação multilinha integrada — entrega QA na8085
11/11/2026. Âmbito `SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1`, expressamente autorizado. Código, interface e percurso efectivo entregues na instalação existente; sem novas demonstrações ou dependência do SGC. [Prova consolidada](proofs/sgf-multi-line-invoice-20261111.json). Não constitui aceitação, checkpoint, publicação ou conformidade fiscal real.
## Resultado utilizável
Em `/sgf`, módulo Comercial → Facturação → **Documento com várias linhas QA**: cabeçalho/cliente e dados documentais,1–25 linhas de serviços, pesquisa paginada, unidade/quantidade/preço e modalidade explícita, totais e diferenças, preparação auditada, cálculo conservado, revisão temporal distinta e emissão de teste/PDF. A configuração das séries permanece separada. O menu horizontal/dropdown aceite é preservado.
|Exemplo existente|Modalidade|Linhas|Base|Imposto QA|Total|Recebível em aberto|
|---|---|---|---:|---:|---:|---:|
|TEST-MULTI-2026/1|Acrescido|400+600|1000|160|1160|1160MZN|
|TEST-MULTI-2026/2|Incluído|464+696|1000|160|1160|1160MZN|
Dois recebíveis explicitamente preparados/revistos; nenhum pagamento, recebimento ou afectação. Duas origens explicitamente preparadas/revistas/aplicadas no mesmo diário em três linhas: D QA-CLIENTES1160 / C QA-VENDAS1000 / C QA-IMPOSTO-TESTE160. **29→31 lançamentos;24 contas intactas.** Não há emissão ou contabilização automática ao guardar/calcular.
Nova preparação/revisão de resultado interno parcial v5/v6, usando a mesma correspondência revista v4:24 contas de plano,23 com movimento,31 lançamentos; activos classificados5575, fornecedor100, rendimentos4586,21, gastos2300, subtotal conhecido2286,21MZN. Dez contas continuam por classificar; capital, resultado completo e balanço completo indeterminados. Os CSV/snapshots19/27 e23/29 mantêm os bytes anteriores. Conferência actual do período somente de leitura:44 factos, zero pendentes, débitos/créditos iguais; período aberto.
## Distribuição de funções sem novas concessões
Os22 nomes de direitos verificados não pertencem todos à mesma pessoa. A implementação conserva a distribuição efectiva:
- Comercial preparação QA8 prepara o rascunho, propõe a regra temporal e emite após revisão; não enumera o catálogo fiscal comum.
- Financeiro preparação QA13, no contexto Comercial explicitamente escolhido, conserva o cálculo com `fiscal_rules.prepare` e consulta a regra com `fiscal_rules.view`. Isto não concede ao Comercial o poder fiscal de13.
- Comercial revisão QA9 decide a versão temporal, com revalidação estritamente vinculada ao cálculo já conservado; não administra regras fiscais.
- Financeiro QA13/14 prepara/revê o recebível no módulo Financeiro.
- Contabilidade QA15/16 prepara/revê/aplica o lançamento no módulo Contabilidade. Consulta conserva os seus direitos existentes.
Nenhum direito, concessão, utilizador, perfil, conta contabilística ou role PostgreSQL novo. Os rascunhos conservam associação **Comercial**. `QualifiedInvoiceSource` permite a leitura qualificada da dependência tipada pelos módulos financeiros autorizados, sem acrescentar associações modulares. A API Comercial directa continua a exigir troca explícita de módulo; a consulta financeira usa o seu próprio percurso. Não introduzir atalhos que contornem essa guarda.
## Mecanismos e protecções
`MultiLineDrafts` compõe atomicamente os escritores originais de prestação, rascunho e preparação. Regista a origem dos novos rascunhos em `mi_drafts`; não adopta ou altera rascunhos antigos. Campos desconhecidos podem permanecer no rascunho; impedem cálculo/emissão em vez de assumir zero. Prévia é revertida; confirmação exige autor/contexto/versões/catálogos actuais e fundamento. Depois de emitida, esta origem não é editável pelo editor novo.
`MultiLineInvoiceCalculation` usa `FiscalRules::invoiceDocument` e o mesmo `FiscalDocumentMath`: truncatura por linha, base incluída=bruto−imposto documental e conservação das diferenças racionais. A captura vincula origem, preparação, unidades, modalidade, regra/vigência, data e identidade/poderes do preparador. Alterações invalidam conferências pendentes; uma recuperação de comando já concluído devolve o comprovativo histórico, sem revalidar uma emissão nova.
`MultiLineInvoiceIssuance` adapta apenas o novo tipo de captura ao **ServiceInvoicing original**, seu contador, eventos, PDF guardado e recibos. O ramo de uma linha mantém o seu contrato. `SalesLedgerSource` confere os totais documentais congelados pelo mesmo cálculo e fornece o mesmo escritor de vendas, sem outro diário. Prévia de emissão não atribui número nem cria dívida. Recibos novos são individuais, isolados e append-only. Catálogos novos têm SQL25/100; decisões da página são lidas conjuntamente. Não se declara que a listagem administrativa antiga passou a ser um registo universal paginado.
Nas operações novas, o bloqueio por empresa serializa confirmações; permissões são novamente conferidas após o bloqueio. O tratamento HTTP traduz espera excedida em503 com indicação para recuperar a mesma chave. A interface conserva pedido/chave quando o resultado é incerto e impede trocar origem/contexto em curso. A recuperação após recarregar a página depende do identificador original/comprovativo; não existe uma caixa de saída persistente nova no navegador.
## Provas dirigidas e reutilizadas
**143 verificações dirigidas,122 de preservação, concorrência separada e conferência de período somente de leitura.** Não somar as passagens iniciais substituídas. As suites alheias de Compras/Stock/RH/activos não foram repetidas.
|Prova final|Verificações|Cobertura|
|---|---:|---|
|Percurso financeiro na regressão|26|Acrescido/incluído,100 incluído86,21+13,79, PDF, recebível, três linhas GL e propriedade Comercial; rollback|
|Controlador|18|Direitos, campos protegidos, SQL25/100, decisões, recibos e unidade estrangeira; rollback|
|Fronteiras|20|Desconhecidos,1/25/26 linhas, identidade/poderes, regra retirada, versões e modalidade; rollback|
|Falha parcial|6|Falha após escritores originais/antes do recibo, rollback integral e recuperação única de rascunho; falha tardia da emissão desfaz contador/factura/PDF/evento/recibo; rollback|
|Interface|21|Handlers com API simulada, escolha explícita, totais, incerteza/mesma operação, revisão distinta/menu; não é navegador|
|HTTP8085|28|Percurso concluído, fotografias/PDF/SHA, permissões e ficheiros efectivamente servidos|
|Resultado interno parcial|12|Duas decisões novas, mesmas correspondências, saldos e exportações históricas|
|Leituras finais HTTP|12|Registo Comercial25/100, posições Financeiras2320, prazo desconhecido e origem/recibos exactos|
|Preservação|122|96 conjuntos originais, appends exactos, três bindings, PDFs anteriores, escritores antigos, contas/diário/período e ficheiros instalados|
Concorrência só na regressão existente: duas emissões com chaves distintas sobre o mesmo documento. A primeira retém a transacção; `pg_blocking_pids`/`pg_stat_activity` identificam a espera Lock/transactionid no bloqueio da empresa. A segunda recebe503 após **5,09s**. Libertada a primeira, a segunda recusa a conferência desactualizada com409; recuperar a primeira devolve200/replayed. Uma factura, um recibo de emissão e último número1. A fixture de concorrência fica identificada exclusivamente na regressão, sem recebível/GL: não é uma pendência da8085 nem deve ser usada para declarar fecho geral da regressão.
Os dois percursos8085 somam22 comandos guardados em progresso privado; mais dois de resultado interno. Não os repetir para apresentação. A retomada do ensaio HTTP reutilizou resultados/chaves conservados após corrigir no harness o endereço de comando GL e a consulta de documento no módulo indevido. Não foram criados documentos suplementares. O teste de período inicialmente procurava uma propriedade `balanced` inexistente: a observação válida foi reutilizada e compararam-se `debit_minor`/`credit_minor`, sem repetir a consulta ou fechar o período.
PDF máximo25linhas: amostra não emitida, cinco páginas renderizadas e inspeccionadas; texto legível, sem corte/colisão observado. O renderizador textual existente pode dividir a descrição de uma linha entre páginas; não é um modelo fiscal oficial nem revisão visual da aplicação. As capturas fornecidas orientaram os campos e percurso, sem importar dados pessoais ou copiar taxas como legislação.
## Preservação e limites
Todas as linhas anteriores dos96 conjuntos conferidos permanecem idênticas. Mantêm-se29 lançamentos originais,24 contas, seis versões/recibos de activos, custo1200/acumulado100/líquido1100, Caixa950, RH, Stock, Compras, permissões/utilizadores e três bindings. Novas escritas limitadas aos dois documentos e suas preparações/decisões/recebíveis/GL e resultado parcial autorizados. Os seis registos protegidos não foram escolhidos ou escritos; nenhum comando de limpeza de cache, migração geral, nova demonstração, publicação, SGC ou candidata de Compras foi executado.
A proposta de vencimento no rascunho não se converte automaticamente em condição do recebível: os dois prazos efectivos permanecem desconhecidos. O livro/mapa IVA anterior conserva quatro versões e não inclui estes documentos. Não presumir que a nova regra artificial16% é taxa legal, que este ciclo valida emissão real ou que foram implementados regimes mistos, isenções, descontos fiscais, artigos/famílias, POS ou Excel.
A instalação continua QA: revisão visual da aplicação, carga sustentada, armazenamento/recuperação operacional, fiscalidade/modelos legais e capacidade de implantação exigem evidência própria. A [matriz v30](integration/sgf-solution-contract.json) mantém `full_product_complete=false` e `client_presentable=false`.
+44
View File
@@ -0,0 +1,44 @@
# Facturação por várias linhas — ciclo integrado QA proposto
11/11/2026. **Autorizado expressamente11/11: «Autorizar a facturação multilinha QA proposta». Implementado e aplicado na mesma8085; ver [entrega](SGF_MULTI_LINE_INVOICE_DELIVERY.md).** Continuação funcional das referências de facturação fornecidas pelo utilizador, na mesma8085 e regressão existente, sem novas demonstrações ou SGC.
## Bloqueio concreto e decisão necessária
O cálculo comum de1–25linhas e de preço com imposto incluído já existe e foi aprovado. Porém, ServiceCalculation::evaluate continua a exigir uma linha/preço acrescido e ServiceInvoicing::reviewInside conserva a mesma restrição. O [âmbito anterior](SGF_MULTI_LINE_INCLUDED_SCOPE.md) diz: «Não emite factura, reconhece dívida, cria pagamento, lançamento ou altera o livro/mapa IVA.» Portanto, não basta retirar uma condição de interface: é preciso autorizar o novo efeito documental/financeiro e a sua ligação ao cálculo revisto.
Proposta agrupada: terminar o percurso de documento por várias linhas no SGF, desde cabeçalho/cliente/linhas até cálculo, revisão, emissão QA/PDF, recebível e contabilização explícita no diário existente. Não propor uma decisão por campo ou etapa.
## Funcionalidade e política
Uma área de trabalho quotidiana reúne cabeçalho, cliente/NUIT reutilizados, moeda/datas/condições, grelha com1–25linhas e resumo de bases/imposto/totais sempre visível. Pesquisa e selecção explícita de serviços existentes; unidade/quantidade/preço por linha; sem importar nomes, contactos ou dados das capturas. Configuração de séries/regras fica em área própria e o menu horizontal/dropdown aceite permanece. Artigos/famílias/POS/importação Excel não são implicitamente implementados por esta grelha.
Reutilizar o núcleo comum e a política já aprovada: imposto documental truncado por linha; preço incluído decompõe base documental como bruto menos imposto documental; conservar exactos e diferenças racionais e somar montantes documentais das linhas. Sem adoptar taxa como lei, perfil real, regimes mistos por linha, isenções presumidas, descontos fiscais novos ou linhas negativas. Campos não suportados não são mostrados como operações disponíveis.
A nova captura é tipada/versionada e vinculada ao rascunho/preparação/perfil/regra/data/modalidade/linhas/unidades/contexto/pessoa. Edição ou mudança de origem invalida a conferência. Prévia não numera nem cria dívida; emissão exige revisão/prontidão e confirmação individual, fixa fotografia/PDF/SHA e número uma única vez pelo writer documental existente. Contabilidade mantém preparação/revisão distinta/aplicação próprias, usando o mesmo escritor de vendas em três linhas; não gerar lançamentos silenciosamente ao guardar.
Os contratos e ramos antigos de uma linha permanecem inalterados. O adaptador novo aplica-se apenas a novas origens deste âmbito QA; nenhum documento/cálculo antigo é recalculado, convertido ou reemitido.
## Exemplos/efeitos pedidos na8085
Reutilizar o cliente fictício autorizado do percurso comercial concluído e os serviços/cadastros QA existentes; escolha por referência exacta, sem usar ou associar os seis registos protegidos. Se for necessário preparar novos rascunhos/prestações, fazê-lo pelos mecanismos actuais, com proveniência Comercial explícita. Preços das linhas são dados de preparação auditados, não alteração automática do catálogo.
Preparar/rever uma série nova **TEST-MULTI-2026**, pela administração já existente. Os números só são atribuídos nas confirmações efectivas; séries e números anteriores ficam intactos. Dois documentos QA de duas linhas:
|Modalidade artificial16%|Linhas|Base documental|Imposto|Total|
|---|---|---:|---:|---:|
|Acrescido|400+600|1000|160|1160MZN|
|Incluído|464+696|1000|160|1160MZN|
A regra QA16% existente é escolhida explicitamente na sua vigência; nenhuma alteração legal de taxa é declarada. O caso100incluído=86,21+13,79, diferenças racionais e limites1/25/26linhas são provas de regressão, sem terceiro documento8085.
Cada documento tem um recebível1160MZN, total novo2320, sem recebimento ou afectação. Cada origem é conferida/aplicada individualmente no mesmo diário: D QA-CLIENTES1160 / C QA-VENDAS1000 / C QA-IMPOSTO-TESTE160. **Dois lançamentos novos,29→31**, preservando os29 originais. Não criar contas: mantém24. Não cria pagamento, Caixa, Stock, compra, custo ou salário.
O relatório interno parcial pode receber uma nova preparação/revisão de resultado sobre as mesmas correspondências aprovadas, sem reescrever snapshots anteriores ou presumir contas por classificar como capital. Mantêm-se as quatro versões do livro/mapa IVA; os dois documentos novos não serão apresentados como incluídos nesse mapa antigo nem como apuramento geral.
## Acessos e execução
**Sem direitos, concessões, utilizadores ou perfis novos.** Conferência SELECT-only nos dois livros encontrou os22 nomes de direitos necessários já distribuídos pelos perfis QA existentes, sem alterar a distribuição. Comercial conserva cadastros/rascunhos; os responsáveis documentais conservam configuração/revisão/emissão que já possuem; Contabilidade conserva preparar/rever/aplicar. Consulta não passa a permitir emissão. Se uma concessão adicional se revelar indispensável, deve ser apresentada antes de a conceder.
Primeiro implementar/provar todo o bloco na regressão existente; depois executar apenas os dois percursos positivos uma vez na8085 com chaves/progresso/recibos privados. Reutilizar as provas válidas de cálculos, emissão original, recebíveis, diário e navegação. Acrescentar apenas fronteiras alteradas: equivalência dos totais/PDF/fotografia/fonte GL, dados desconhecidos, modalidade/linhas divergentes, permissões/empresa/unidade/pessoa, versão/data alteradas, concorrência/numeração/idempotência e recuperação após falhas parciais, sem números/documentos/dívida/diários duplicados.
Preservar os seis registos, cache bloqueada,29 lançamentos/24 contas, activo1200/100/1100, Caixa950, RH, Stock, compras e snapshots/provas aceites. Sem novas demonstrações, SGC, dados/acessos/operações reais, emissão fiscal legal, comunicação/envio à AT/cliente, modelos oficiais, publicação, candidata de Compras, reabertura, checkpoint ou aceitação presumida. Visual, carga sustentada e implantação mantêm pendências separadas.
@@ -0,0 +1,97 @@
{
"version": "SGF-FIXED-ASSETS-INTEGRATED-QA-v1",
"approved": true,
"implemented": true,
"applied": true,
"scope_file": "docs/SGF_FIXED_ASSETS_SCOPE.md",
"module": "accounting",
"books": [
"existing8085",
"existing_regression"
],
"scope": {
"tenant_id": 1,
"company_id": 2,
"unit_id": 2
},
"new_host_rights": [
"fixed_assets.view",
"fixed_assets.prepare",
"fixed_assets.review"
],
"grants_per_book": [
{
"user_id": 15,
"permission": "fixed_assets.view"
},
{
"user_id": 16,
"permission": "fixed_assets.view"
},
{
"user_id": 17,
"permission": "fixed_assets.view"
},
{
"user_id": 15,
"permission": "fixed_assets.prepare"
},
{
"user_id": 16,
"permission": "fixed_assets.review"
}
],
"grant_count_per_book": 5,
"total_grants_both_books": 10,
"new_users": 0,
"new_profiles": 0,
"new_postgres_grants": 0,
"new_public_rights": 0,
"new_demo": false,
"new_accounts_per_book": [
{
"code": "QA-ACTIVO-CUSTO",
"purpose": "Custo bruto do activo fictício",
"statement_group": "assets"
},
{
"code": "QA-ACTIVO-DEP-ACUM",
"purpose": "Depreciação acumulada (saldo credor no activo)",
"statement_group": "assets"
},
{
"code": "QA-ACTIVO-GASTO-DEP",
"purpose": "Gasto de depreciação QA",
"statement_group": "expenses"
},
{
"code": "QA-ACTIVO-ORIGEM-QA",
"purpose": "Contrapartida artificial de abertura, sem capital ou dívida presumidos",
"statement_group": "unclassified"
},
{
"code": "QA-ACTIVO-GASTO-ABATE",
"purpose": "Perda interna no abate sem contraprestação",
"statement_group": "expenses"
}
],
"original_journals_preserved": 27,
"proposed_new_journals_8085": 2,
"journal_count_after_8085": 29,
"original_accounts_preserved": 19,
"account_count_after_8085": 24,
"new_cash_or_debt_effects": false,
"typed_common_writer_extension_required": true,
"new_statement_mapping_and_result_commands_8085": 4,
"evidence": [
"C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-103845-dbdf9215/assets-access-readonly.json",
"C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-103847-36def480/assets-access-readonly.json"
],
"authorization": {
"date": "2026-11-11",
"decision": "Autorizar o ciclo integrado de activos QA"
},
"delivery": "docs/SGF_FIXED_ASSETS_DELIVERY.md",
"proof": "docs/proofs/sgf-fixed-assets-20261111.json",
"application_limits": "Only both existing QA books; no real accesses or production activation."
}
@@ -1,8 +1,8 @@
{
"version": "SGF-HR-CONTRACTS-ATTENDANCE-LEAVE-QA-v1",
"approved": true,
"implemented": false,
"applied": false,
"implemented": true,
"applied": true,
"scope_file": "docs/SGF_HUMAN_RESOURCES_PERSONNEL_SCOPE.md",
"module": "human_resources",
"books": [
@@ -110,5 +110,6 @@
"authorization": {
"date": "2026-11-11",
"decision": "Autorizar o bloco e as30 concessões QA apresentadas"
}
},
"delivery": "docs/SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md"
}
@@ -0,0 +1,39 @@
{
"version": "SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1",
"approved": true,
"implemented": true,
"applied": true,
"scope_file": "docs/SGF_MULTI_LINE_INVOICE_SCOPE.md",
"target": "existing8085",
"regression": "existing_book",
"new_demo": false,
"sgc_required": false,
"new_rights": [],
"new_grants": 0,
"new_users": 0,
"new_accounts": 0,
"existing_permission_names_verified": 22,
"new_documents": 2,
"new_receivables_minor": [
116000,
116000
],
"new_journals": 2,
"before_journals": 29,
"after_journals": 31,
"chart_accounts_preserved": 24,
"new_payments": 0,
"cash_preserved_minor": 95000,
"protected_records_preserved": 6,
"cache_cleared": false,
"new_series": "TEST-MULTI-2026",
"policy_reused": "SGF-MULTI-LINE-INCLUDED-QA-v1",
"decision_required": "Autorizar a facturação multilinha QA proposta",
"reason": "Previous calculation scope expressly excludes invoices, debt and journals; current invoicing engine still restricts one line and added price.",
"authorization": {
"date": "2026-11-11",
"decision": "Autorizar a facturação multilinha QA proposta"
},
"delivery": "docs/SGF_MULTI_LINE_INVOICE_DELIVERY.md",
"proof": "docs/proofs/sgf-multi-line-invoice-20261111.json"
}
+92 -20
View File
@@ -1,5 +1,5 @@
{
"version": "SGF-SOLUTION-CLOSURE-v25",
"version": "SGF-SOLUTION-CLOSURE-v30",
"target": "existing8085",
"sgc_required": false,
"new_demonstrations": false,
@@ -16,7 +16,8 @@
"Recibo de afectação efectiva400, crédito nominal100/saldo500 e factura-recibo1000 atómica",
"PDF/SHA, histórico e anulação técnica com reversão explícita e diário único",
"Registo unificado autorizado com seis famílias, filtros/SQL25/100 e projecções limitadas à página",
"Início operacional com percurso, evidências, funções autorizadas e consulta explícita das fontes existentes"
"Início operacional com percurso, evidências, funções autorizadas e consulta explícita das fontes existentes",
"Editor unificado1–25 serviços, imposto acrescido/incluído, cálculo comum revisto, PDF e passagem explícita recebível/diário QA"
],
"required_host_tables": [
"a_clients",
@@ -32,7 +33,12 @@
"sd_versions",
"sd_outputs",
"sd_commands",
"sd_cancellations"
"sd_cancellations",
"mi_meta",
"mi_calculations",
"mi_commands",
"mi_draft_meta",
"mi_drafts"
],
"required_source_files": [
"app/Modules/Services/ServiceWorkflow.php",
@@ -44,14 +50,21 @@
"app/Modules/Services/BillingRegister.php",
"resources/js/BillingRegisterWorkspace.jsx",
"resources/js/WorkspaceHome.jsx",
"resources/js/workspace-overview.js"
"resources/js/workspace-overview.js",
"app/Modules/Services/MultiLineInvoiceCalculation.php",
"app/Modules/Services/MultiLineInvoiceIssuance.php",
"app/Modules/Services/MultiLineDrafts.php",
"app/Modules/Services/QualifiedInvoiceSource.php",
"resources/js/MultiLineInvoiceWorkspace.jsx"
],
"proofs": [
"docs/proofs/invoice-test-results.json",
"docs/proofs/sgf-six-module-functional-review-20261110.json",
"docs/SGF_SERVICE_DOCUMENTS_DELIVERY.md",
"docs/SGF_BILLING_REGISTER_DELIVERY.md",
"docs/SGF_OPERATIONAL_INFORMATION_DELIVERY.md"
"docs/SGF_OPERATIONAL_INFORMATION_DELIVERY.md",
"docs/SGF_MULTI_LINE_INVOICE_DELIVERY.md",
"docs/proofs/sgf-multi-line-invoice-20261111.json"
],
"work_remaining": [
"Revisão visual, aceitação explícita e conformidade real; emissão fiscal real permanece bloqueada",
@@ -309,7 +322,8 @@
"Início operacional com percurso, evidências, funções autorizadas e consulta explícita das fontes existentes",
"Submenus distintos e balancete/razão por datas explícitas, escolha de conta por linha, páginas25/100, detecção de mudança de livro e CSV da página",
"Controlo administrativo QA de obrigação/prazo/responsável do mapa interno revisto, preparação13/revisão14, correcção/cancelamento, entrega apenas simulada com SHA verificado, histórico/recibo/SQL25/100/CSV; sem calendário legal ou submissão",
"Correspondências explícitas revistas15/16 e balanço/resultados internos parciais QA:19contas/nove pendentes, resultado congelado19linhas/SHA, história/recibo/CSV contextual, ligação exacta ao razão; capital e resultado completo não presumidos"
"Correspondências explícitas revistas15/16 e balanço/resultados internos parciais QA:19contas/nove pendentes, resultado congelado19linhas/SHA, história/recibo/CSV contextual, ligação exacta ao razão; capital e resultado completo não presumidos",
"Activos QA: cadastro/custódia/plano/quota revistos, diário único1200/100 e líquido1100; correcção/abate/fecho do recorte na regressão"
],
"required_host_tables": [
"ca_proposals",
@@ -321,7 +335,10 @@
"fr_commands",
"fo_meta",
"fo_versions",
"fo_commands"
"fo_commands",
"fa_meta",
"fa_versions",
"fa_commands"
],
"required_source_files": [
"app/Modules/Accounting/GeneralLedger.php",
@@ -357,7 +374,12 @@
"resources/js/statement-download.js",
"resources/js/FiscalObligationsWorkspace.jsx",
"database/sql/internal_statements.sql",
"config/internal_statements.php"
"config/internal_statements.php",
"app/Modules/Accounting/FixedAssets.php",
"app/Modules/Accounting/FixedAssetLedgerSource.php",
"app/Modules/Accounting/FixedAssetJournalController.php",
"resources/js/FixedAssetsWorkspace.jsx",
"packages/general-ledger/src/LedgerAssets.php"
],
"proofs": [
"docs/proofs/purchasing-completion-matrix-final-20261104.json",
@@ -369,11 +391,14 @@
"docs/SGF_OPERATIONAL_INFORMATION_DELIVERY.md",
"docs/SGF_ACCOUNTING_REPORTS_DELIVERY.md",
"docs/SGF_FISCAL_OBLIGATIONS_DELIVERY.md",
"docs/SGF_FINANCIAL_STATEMENTS_DELIVERY.md"
"docs/SGF_FINANCIAL_STATEMENTS_DELIVERY.md",
"docs/SGF_COVERAGE_RECONCILIATION.md",
"docs/proofs/sgf-fixed-assets-20261111.json"
],
"work_remaining": [
"Conferência visual restante e aceitação explícita do conjunto; ver SGF_SIX_MODULE_CLOSURE_20261110.md",
"Backlog fiscal/financeiro amplo pendente; tabela de cobertura não implementa motores ou modelos"
"Backlog fiscal/financeiro amplo pendente; tabela de cobertura não implementa motores ou modelos",
"Activos gerais e tratamento fiscal/legal, alienação/contraprestação, correcções de custo já admitido e cadeias adicionais fora do recorte QA"
],
"required_native_tables": [
"gl_accounts",
@@ -384,7 +409,9 @@
"fs_versions",
"fs_results",
"fs_result_lines",
"fs_commands"
"fs_commands",
"gl_asset_books",
"gl_asset_extension_versions"
]
},
{
@@ -396,29 +423,46 @@
"Regras artificiais versionadas com vigência e fonte explícitas",
"Processamento exacto por trabalhador/período com escolha de fontes e revisão distinta",
"Correcção/cancelamento versionados, prévia, histórico, recibos e recuperação",
"Mapa individual revisto conservado com SHA e exportação autenticada; filtros SQL25/100"
"Mapa individual revisto conservado com SHA e exportação autenticada; filtros SQL25/100",
"Contratos internos QA revistos com correcção/terminação/cancelamento e dependências",
"Plano e assiduidade por dia com intervalos explícitos, dados desconhecidos e revisão distinta",
"Condição de férias QA, reserva/gozo/cancelamento individuais, saldo efectivo sem duplicação",
"Conferência parcial do período por pessoa, fontes/versões e CSV/SHA conservado",
"Ligação contratual explícita opcional em novas preparações salariais, provada na regressão sem alterar salários8085",
"Resumo CSV legível derivado de versão revista, com SHA próprio/original, sem alterar o artefacto conservado"
],
"required_host_tables": [
"rm_meta",
"rm_versions",
"rm_commands",
"hr_qa_profiles"
"hr_qa_profiles",
"hp_meta",
"hp_versions",
"hp_commands"
],
"required_source_files": [
"app/Modules/HumanResources/Remunerations.php",
"app/Modules/HumanResources/Http/RemunerationsController.php",
"app/Shared/Installation/HumanResourcesProfiles.php",
"resources/js/RemunerationsWorkspace.jsx",
"resources/js/statement-download.js"
"resources/js/statement-download.js",
"app/Modules/HumanResources/Personnel.php",
"app/Modules/HumanResources/PersonnelTime.php",
"app/Modules/HumanResources/Http/PersonnelController.php",
"resources/js/PersonnelWorkspace.jsx",
"app/Modules/HumanResources/PersonnelReadableCsv.php"
],
"proofs": [
"docs/SGF_HUMAN_RESOURCES_DELIVERY.md",
"docs/proofs/sgf-human-resources-20261111.json"
"docs/proofs/sgf-human-resources-20261111.json",
"docs/SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md",
"docs/proofs/sgf-hr-personnel-20261111.json",
"docs/SGF_HUMAN_RESOURCES_READABLE_DELIVERY.md"
],
"work_remaining": [
"Contratos/alterações contratuais, assiduidade, turnos, férias e ausências",
"Regimes, IRPS/INSS/modelos legais e ligação salarial a dívida/pagamentos/Contabilidade",
"Revisão visual, carga sustentada e capacidade de implantação"
"Conformidade e aplicabilidade laboral real: contratos legais, turnos/calendário, férias legais, benefícios e modelos",
"Cálculo legal IRPS/INSS e ligação a dívida/pagamento/Contabilidade sem efeitos implícitos",
"Revisão visual, carga sustentada, armazenamento e capacidade de implantação"
]
}
],
@@ -458,7 +502,7 @@
],
"acceptance_proof": "docs/proofs/sgf-single-solution-acceptance.json",
"scope_policy": "Recorte QA dos seis módulos e requisitos amplos de produto separados explicitamente; nenhum painel de cobertura equivale à implementação dos motores ou modelos pendentes.",
"revision_note": "A prova funcional anterior conserva o contrato v1 histórico; as lacunas agora discriminadas não são aceitação nem capacidade de implantação.",
"revision_note": "11/11: activos QA integrados na8085, origem1200/quota100 no diário único,29 lançamentos/24 contas; demonstração nova parcial23 contas com movimento e snapshot antigo19/27 intacto. Produto amplo não concluído; revisão visual/carga/legal/implantação separadas.",
"proposed_scope_extensions": [
{
"id": "cash",
@@ -529,6 +573,23 @@
"access": "docs/integration/sgf-statements-access-proposal.json",
"approval_required_for_new_qa_rights": false,
"implemented": true
},
{
"id": "fixed_assets_qa",
"scope": "docs/SGF_FIXED_ASSETS_SCOPE.md",
"access": "docs/integration/sgf-fixed-assets-access-proposal.json",
"approval_required_for_new_qa_rights": false,
"implemented": true,
"delivery": "docs/SGF_FIXED_ASSETS_DELIVERY.md"
},
{
"id": "multi_line_invoice_qa",
"scope": "docs/SGF_MULTI_LINE_INVOICE_SCOPE.md",
"decision": "docs/integration/sgf-multi-line-invoice-proposal.json",
"approval_required_for_new_effects": false,
"new_rights": [],
"implemented": true,
"delivery": "docs/SGF_MULTI_LINE_INVOICE_DELIVERY.md"
}
],
"full_product_complete": false,
@@ -545,7 +606,7 @@
"status": "partial",
"implemented": false,
"basis": "docs/SERVICE_INVOICE_CALCULATION_SPEC.md sections10/11",
"description": "Cadastro/regras/processamento interno artificial QA em RH autónomo entregue; operações laborais gerais, cálculo legal IRPS/INSS/modelos e ligação salarial financeira/contabilística pendentes"
"description": "RH autónomo QA com cadastro/contratos/assiduidade/férias e remunerações internas entregue; regimes laborais gerais, IRPS/INSS/modelos legais e ligação financeira/contabilística salarial pendentes"
},
{
"id": "irpc",
@@ -588,6 +649,17 @@
"implemented": false,
"basis": "docs/SERVICE_INVOICE_CALCULATION_SPEC.md sections10/11",
"description": "Dois critérios/indicadores QA com fontes conciliadas entregues; rácios gerais, critérios reais e tendências/séries permanecem pendentes"
},
{
"id": "fixed_assets",
"status": "partial",
"implemented": false,
"basis": "docs/SERVICE_INVOICE_CALCULATION_SPEC.md FIS-SGF-15",
"description": "Ciclo integrado QA entregue na8085: cadastro/plano/quota/diário/relatórios e ramos de correcção/abate/fecho do recorte na regressão. Operação geral/legal de activos e taxas fiscais continuam pendentes.",
"scope": "docs/SGF_FIXED_ASSETS_SCOPE.md",
"access_proposal": "docs/integration/sgf-fixed-assets-access-proposal.json",
"qa_implemented": true,
"delivery": "docs/SGF_FIXED_ASSETS_DELIVERY.md"
}
],
"client_delivery_sources": [
@@ -0,0 +1,33 @@
{
"passed": true,
"date": "2026-11-11",
"directed_checks": 15,
"identical_original_tables": 66,
"new_grants": 0,
"business_commands": 0,
"cache_cleared": false,
"new_demo": false,
"visual_review": false,
"evidence": [
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-103843-f515a584/coverage-reconciliation.json",
"sha256": "6154e9b5cc8ba35db480fdb330a33c5857d372cb2ed64ea9e5985eaa12a3c028",
"checks": 7
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/coverage-reconciliation-http-20261111.json",
"sha256": "78f94a8cc1f758e3ffe06dc23986ab574c5929ac8bacda3f430cc0d4ec9a90cb",
"checks": 8
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-103940-12c6c0e7/coverage-reconciliation-installation.json",
"sha256": "477e55ca47bf452a26d83aac316c8fcd621e5c5f5eecfb658a7281187dd3c358",
"checks": 0
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-104407-f54005b6/coverage-reconciliation-preservation.json",
"sha256": "decb9c3c3c42f78b6826c547a242b9c5e489db22790d89b192da4e55fafb0846",
"checks": 0
}
]
}
+101
View File
@@ -0,0 +1,101 @@
{
"passed": true,
"date": "2026-11-11",
"installation": "existing8085",
"contract": "SGF-FIXED-ASSETS-INTEGRATED-QA-v1",
"checks_by_group": {
"functional": 17,
"correction": 16,
"boundaries": 23,
"journal_boundaries": 9,
"period_sources": 2,
"writeoff_and_close": 5,
"ui_handlers": 15,
"http8085": 25,
"statements_http": 14,
"source_version": 3,
"final_read_and_coverage": 3,
"sequential_quota_writeoff_guards": 6
},
"new_checks_total": 141,
"additional_assertions": {
"atomic_failure_reused_from_completed_assertion_before_later_harness_error": true,
"current_period_facts40_pending0": true,
"current_period_balanced": true
},
"concurrency": {
"during_lock": 503,
"after_release": 409,
"original_replay": 200,
"seconds_waiting": 5.242017030715942,
"blocker": "host company row FOR UPDATE, transactionid wait",
"approved_versions": 2,
"receipts": 2
},
"preservation": {
"checks": 87,
"original_sets": 78,
"original_rows_identical": true,
"bindings_unchanged": 3,
"approved_appends": {
"host.permission_grants": 5,
"native.gl_journals": 2,
"native.audit_logs": 4,
"native.fs_versions": 4,
"native.fs_results": 1,
"native.fs_result_lines": 23,
"native.fs_commands": 4,
"native.gl_accounts": 5,
"native.gl_rules": 6,
"native.gl_proposals": 2,
"native.gl_reviews": 2,
"native.gl_lines": 4,
"native.gl_commands": 6,
"native.gl_events": 6
}
},
"new8085": {
"asset_records": 1,
"administrative_versions": 6,
"administrative_receipts": 6,
"grants": 5,
"new_accounts": 5,
"journals": 2,
"old_journals_preserved": 27,
"total_journals": 29,
"chart_accounts": 24,
"statement_accounts_with_movements": 23,
"statement_new_commands": 4,
"cost_minor": 120000,
"accumulated_minor": 10000,
"carrying_minor": 110000
},
"arithmetic13_reused_not_rerun": true,
"old_snapshot19_accounts27_journals_preserved": true,
"old_suites_reused": true,
"legacy_ssr_navigation_runner": "Terminated without a result; imports/expectations predate accepted dropdown. Not counted as passed. New asset handler/navigation checks and private build passed.",
"period_test_limits": "Asset-only regression harness closed and rolled back. Application8085 period read40facts0pending only; no closure executed.",
"browser_visual_review": false,
"sustained_load_validated": false,
"deployment_validated": false,
"full_product_complete": false,
"new_demo": false,
"sgc_required": false,
"cache_cleared": false,
"published": false,
"installed_final_source_sha256": {
"app/Modules/Accounting/FixedAssets.php": "390051a6bf8d7fc02cad111b707ea4bceba1c8d39986af146370c0191d6de760",
"app/Modules/Accounting/FixedAssetLedgerSource.php": "3facfc05db0636f01fd84f97f76d19cd2efd1599dfe4ab20a53ca4c8fe077f9c",
"app/Modules/Accounting/FixedAssetJournalController.php": "eba98630909ee5f7b5cd18718dbebf599b408eccdb201c3d171ae80b4dfb1502",
"app/Modules/Accounting/ComposedLedger.php": "25e14af7f62dea2f205f9946135fb016580d1ca2446c9303313d76c2ccd99e04",
"packages/general-ledger/src/LedgerAssets.php": "aa5b0e7766c02587d28d55c8566b6f186ffbca60c180401ba0ead31f9a426cfe",
"packages/general-ledger/src/PayableJournal.php": "2c45e2c5635d4ae60577f6261bf749f9ca91bb09435fcbe373d58a265583c1be",
"packages/general-ledger/src/LedgerCycle.php": "4defe64d7c65165c4f4567628b43b387c82c8219ada26dc3119d5d37635ec63b",
"packages/general-ledger/bootstrap.php": "748fd49400440b5d09996f3bad5613053f7094ae5903aa7f6169e7c945e4d26b",
"app/Shared/Context/ModuleNavigation.php": "36d94a8131290d77fb0ae5e5a308e28cbb4c7644455d654c1b979f2324292e4a",
"app/Modules/Identity/Services/IdentityWorkflow.php": "79a2bd9e91a77b4fe4c36efa863e4ca843ae0464e75dc9f54ba21a59beb7020b",
"routes/web.php": "f45e10e0dd7feef43767956d0b46663f46d3845e48c0f40c3980c9e930dda0f2",
"app/Modules/Services/FiscalCoverage.php": "592d56e2e19a21c9955eddf4bd2c83af610f87536bc0d6ed85b034b797bf2791"
},
"last_changes_read_only_verified": true
}
+137
View File
@@ -0,0 +1,137 @@
{
"passed": true,
"date": "2026-11-11",
"installation": "cc50a276c10c",
"port": 8085,
"module": "human_resources",
"directed_checks": 233,
"affected_compatibility_checks": 56,
"preservation_checks": 78,
"new_grants_both_existing_books": 30,
"new_users": 0,
"individual_commands_8085": 16,
"new_salary_versions_8085": 0,
"new_journals": 0,
"new_payments": 0,
"new_demo": false,
"cache_cleared": false,
"protected_six_targeted": false,
"browser_visual_review": false,
"deployment_validated": false,
"evidence": [
{
"name": "core",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-085805-d6985581/hp-result.json",
"sha256": "b5f584c55f1cba6b6cf52066a24f76a32c8d257cec0556cb2bf0a3e7eadaa8dc",
"checks": 21
},
{
"name": "boundaries",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-085910-105a205c/hp-boundaries.json",
"sha256": "1180d1d3750e388ff5f0a05d1edce3faee2ce967f0820121916b5e52088668ed",
"checks": 26
},
{
"name": "privacy",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-090216-f627a6b2/hp-privacy.json",
"sha256": "3039bc3baba6ba1247b2efbc1edf4034f193241246ef856cc3927422c8b72ae9",
"checks": 9
},
{
"name": "time",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-090139-572f98b6/hr-personnel-time-core.json",
"sha256": "bbe501658c5c2b9832772c033dc09e74d823450345f8bd833fb45c126ec8d09b",
"checks": 23
},
{
"name": "ui",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-ui-20261111.json",
"sha256": "7ed05fd4d86a9d39952cd895e0db0d3eccf1fb3606fbf95d7cb1725b12e2523a",
"checks": 19
},
{
"name": "balances",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-090617-cbd50595/hp-current-balances.json",
"sha256": "df7189fdf4cd3bd021c3b25eb20c647163558f84f669efbc868fb38c8a4cabcc",
"checks": 8
},
{
"name": "refusal",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-090954-e38c2dc5/hp-refusal.json",
"sha256": "c5f487a72036f61f1c7bdbaeea152ad240705eabfa3349199340bd23de6117e5",
"checks": 7
},
{
"name": "http",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-http-20261111.json",
"sha256": "b449e88a1bb980ed5f5747e09e7e7893271c247d7774d5f24aa63951f131aa38",
"checks": 97
},
{
"name": "final_http",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-final-http-20261111.json",
"sha256": "00b9d2febacf6bfac8fe2dcc742e5ff7528ccbf268fb6db76e192708201f253f",
"checks": 18
},
{
"name": "final_assets",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-final-assets-20261111.json",
"sha256": "ce63881589643172007f473b632071dcbaf576ed337ffee0c8e45830339ca084",
"checks": 3
},
{
"name": "final_ui",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-final-ui-20261111.json",
"sha256": "3e6bf571464e9f66f843464381bdcf91a856dc7a13cbe9ff79a2dca19c37c040",
"checks": 2
},
{
"name": "compat_salary",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-090053-0013d8b7/remunerations-result.json",
"sha256": "9ac93a64316a4c7d8168e2977ddb2f4364ff9c49f6c80b9b6f070600e6cd2e4f",
"checks": 40
},
{
"name": "compat_salary_ui",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-remunerations-ui-20261111.json",
"sha256": "48480a08f3e06d1851897c206b9eb64209c7f826ad63d5c2647b23d178fe7dff",
"checks": 16
},
{
"name": "concurrency",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/hp-concurrency-1794380380400/result.json",
"sha256": "98343e82d1b47b10e60602fbb81aab31e4cb54e96eb5018d9dd09802535187b5",
"checks": 0
},
{
"name": "preservation",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-091110-94c14a72/hp-preservation.json",
"sha256": "59ac15dfe6ce0f8139d9b90a1eb1cbaa1bc315ac1a2b6a81bee1c7be3f32f78e",
"checks": 78
},
{
"name": "installation",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-090956-998c70ef/hp-installation.json",
"sha256": "b1634a18931d7e8455c054d4cae91f6fecc6a38e7c6bdc7eed05ea275deab97a",
"checks": 0
},
{
"name": "reg_setup",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-085106-4ec92771/hp-setup.json",
"sha256": "4dc61775465ad1ce91fd5712dd4f9b6a1d6cea94bd1f824027de7c10d5438c3a",
"checks": 0
},
{
"name": "8085_setup",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-085107-4f556a85/hp-setup.json",
"sha256": "cb01234621369a924d933e09744e24d475492c46064d3c82f5dfb23c1d28b02b",
"checks": 0
},
{
"name": "finalization",
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-091440-125e5a2d/hp-finalization.json",
"sha256": "ec0f3c3c152f5185bd3f6493109ce21fda7d892791d80f405bf3165ec7f5a610",
"checks": 0
}
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"passed": true,
"date": "2026-11-11",
"installation": "cc50a276c10c",
"port": 8085,
"directed_checks": 58,
"identical_original_tables": 66,
"business_commands_8085": 0,
"new_grants": 0,
"new_profiles": 0,
"cache_cleared": false,
"protected_six_targeted": false,
"browser_visual_review": false,
"deployment_validated": false,
"evidence": [
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-b4093c6f6707/single-integration/20261111-095512-0ff4f79e/hp-readable-result.json",
"sha256": "d246d5aea13cf9c3fee44e4e9b9198f8ee034967c9f4884983a966a0d86eafd1",
"checks": 17
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-readable-download-20261111.json",
"sha256": "6e5a4963862cda1a906498454eb3ec6f24ead4599a9a822dbea1ff1b53950cac",
"checks": 11
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-readable-ui-20261111.json",
"sha256": "ef91bcdd098eee3dd627cf5cfd73df4c6be24ce870ec38e02394fb6718428ec3",
"checks": 3
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/hp-readable-http-20261111.json",
"sha256": "1613c8edf712e7e04673d424ff6cacde6473e24fbce4e7e75611a429ad9931c6",
"checks": 27
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-095619-e3fff98c/hp-readable-installation.json",
"sha256": "fdc157d3b3a597a9abe7453beb1b742559faae59a2fe8495953b799fc72de3e0",
"checks": 0
},
{
"file": "C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-cc50a276c10c/single-integration/20261111-095812-c5cef31b/hp-readable-preservation.json",
"sha256": "decb9c3c3c42f78b6826c547a242b9c5e489db22790d89b192da4e55fafb0846",
"checks": 0
}
]
}
@@ -0,0 +1,143 @@
{
"date": "2026-11-11",
"policy": "SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1",
"passed": true,
"installation": "cc50a276c10c",
"port": 8085,
"new_demo": false,
"sgc_dependency": false,
"new_rights": 0,
"new_grants": 0,
"new_users": 0,
"new_accounts": 0,
"functional_checks": 143,
"preservation_checks": 122,
"evidence": {
"financial": {
"passed": true,
"sha256": "b4bcc83818e48dab9330e6aa7df9b3d16748e6bc7463515ab215386c53161745",
"checks": 26
},
"controller": {
"passed": true,
"sha256": "9323c91c1435c92adc234bed5efac71244ff4de647f425221b30700964d65235",
"checks": 18
},
"boundaries": {
"passed": true,
"sha256": "86ca6422aedb175dc92b4a3101819ebdf71e64cbd19cb9f3b25e7deaa3892bb1",
"checks": 20
},
"recovery": {
"passed": true,
"sha256": "b8483f0a923bc2fcd4ed11d4f2002b3894b84e39e9ab5c29f9b9f675bb893bcb",
"checks": 6
},
"ui": {
"passed": true,
"sha256": "0b5f0aee35a9cbad21c8d39ec7030a737a26a5f9f1d43852e561c08f70b15375",
"checks": 21
},
"http": {
"passed": true,
"sha256": "313f2fc804fdf6b1caf857efed417555b211aab714880fa92d7769994095c77b",
"checks": 28
},
"statements": {
"passed": true,
"sha256": "49c5149b634a5ea6c7245c159d8662afdd225fd0e4b356bdaf76f94db2d6a8e7",
"checks": 12
},
"final_read": {
"passed": true,
"sha256": "121e57c1a1df92e1851250efe2c10f4d498a0a77a9751f437e4e96a1dd9595c1",
"checks": 12
},
"preservation": {
"passed": true,
"sha256": "5f8eebb052df1f4a4539d6704e926a76a0f0d08eae61eae4ebe6fe6186cebc98",
"checks": 122
},
"concurrency": {
"passed": true,
"sha256": "837f72bdf8e842e4d054a2261135e2d4b9208bd4387b3a868686965d59932502",
"during_lock": 503,
"seconds_waiting": 5.086719036102295,
"after_release": 409,
"original_replay": 200,
"invoices": 1,
"receipts": 1,
"last_number": 1,
"regression_only": true,
"pending_receivable_and_journal": true
},
"period": {
"passed": true,
"sha256": "db4802a351a5b8e46ba9e67dfbe5beb25e19f17d22033d045ed6c7404537e128"
}
},
"actual_effects": {
"series": "TEST-MULTI-2026",
"documents": 2,
"numbers": [
1,
2
],
"documentary_base_each": "1000.00",
"documentary_tax_each": "160.00",
"total_each": "1160.00",
"uncollected_receivables": 2,
"uncollected_total": "2320.00",
"new_payments": 0,
"journals_before": 29,
"journals_after": 31,
"chart_accounts": 24,
"current_period_facts": 44,
"current_period_pending": 0,
"current_period_balanced": true,
"period_closed": false,
"cash_unchanged": "950.00",
"new_statement_versions": [
5,
6
],
"statement_mapping_version_unchanged": 4,
"statement_known_result_subtotal": "2286.21",
"statement_unclassified_accounts": 10,
"statement_complete_result": null,
"iva_old_versions_unchanged": 4
},
"preservation": {
"original_sets": 96,
"original_rows_identical": true,
"bindings_unchanged": 3,
"protected_six_not_written": true,
"cache_cleared": false,
"prior_payroll_stock_asset_documents_unchanged": true
},
"pdf_review": {
"sample_25_lines_not_issued": true,
"rendered_pages": 5,
"pages_inspected": [
1,
2,
3,
4,
5
],
"text_legible_no_clipping_observed": true,
"browser_review": false,
"limitation": "Existing text renderer can split a line section across pages; this is not visual acceptance of the application."
},
"limits": [
"QA only; no legal invoice validity or tax-rate assertion.",
"No mixed regimes, exemptions, discounts, stock-product/POS or Excel import implemented by this block.",
"Dates and validity are revalidated; an unissued old-day calculation requires a new conference.",
"Receivable maturity is unknown until a separate explicit agreement; draft proposed date is not propagated.",
"Old IVA snapshot does not include these two documents.",
"UI recovery keeps the operation in memory; reload recovery uses the original private key/receipt and does not automatically reissue.",
"Existing legacy administrative listing is not converted to a bounded universal register.",
"Regression concurrency leaves one identified synthetic invoice without receivable/journal in regression only; it is not an8085 pending fact.",
"No sustained-load/deployment capability or user acceptance declared."
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@ require_once __DIR__.'/src/IndustrialSchema.php';
require_once __DIR__.'/src/IndustrialCorrectionSchema.php';
require_once __DIR__.'/src/CostCorrectionSchema.php';
require_once __DIR__.'/src/CycleSchema.php';
require_once __DIR__.'/src/LedgerAssets.php';
require_once __DIR__.'/src/PayableJournal.php';
require_once __DIR__.'/src/StockSchema.php';
require_once __DIR__.'/src/StockValuation.php';
@@ -0,0 +1,14 @@
<?php
namespace Tempo\Finance\GeneralLedger;
/** Typed source capture only; all prepare/review/post transactions remain in PayableJournal. */
trait LedgerAssets
{
private function captureAssets(array $s,string $date,object $period,object $rule,int $amount):array
{
$meta=$this->db->table('gl_asset_books')->where($this->scope)->first();$kind=$s['effect_type'];if(!$meta||$meta->policy!=='QA-ASSET-JOURNAL-v1'||$meta->host_database!==($s['host_database']??null)||$rule->policy!=='QA-ASSET-JOURNAL-v1'||$rule->effect_type!==$kind||$rule->currency!=='MZN'||$date<$rule->valid_from||$date>$rule->valid_to||$s['permitted_period_key']!==$period->period_key)throw new \DomainException('Origem de activos fora da composição ou regra revista.');
$correction=in_array($kind,['assets.depreciation.reversal','assets.depreciation.replacement'],true);if((!$correction&&$date!==$s['effect_on'])||($correction&&$date<$s['original_journal_on']))throw new \DomainException('Data da origem de activos incompatível.');
$parts=match($kind){'assets.admitted'=>[['QA-ACTIVO-CUSTO',$amount,0],['QA-ACTIVO-ORIGEM-QA',0,$amount]],'assets.depreciation','assets.depreciation.replacement'=>[['QA-ACTIVO-GASTO-DEP',$amount,0],['QA-ACTIVO-DEP-ACUM',0,$amount]],'assets.depreciation.reversal'=>[['QA-ACTIVO-DEP-ACUM',$amount,0],['QA-ACTIVO-GASTO-DEP',0,$amount]],'assets.writeoff'=>[['QA-ACTIVO-DEP-ACUM',$s['accumulated_minor'],0],['QA-ACTIVO-GASTO-ABATE',$s['carrying_minor'],0],['QA-ACTIVO-CUSTO',0,$s['cost_minor']]],default=>throw new \DomainException('Tipo de activo não suportado.')};
$parts=array_values(array_filter($parts,fn($p)=>$p[1]+$p[2]>0));$accounts=$this->q('accounts')->whereIn('code',array_column($parts,0))->get()->keyBy('code');$lines=[];foreach($parts as[$code,$debit,$credit]){$a=$accounts->get($code);if(!$a||!$a->is_movement||$a->currency!=='MZN')throw new \DomainException('Conta de activo incompatível.');$lines[]=['account_id'=>$a->id,'account_code'=>$code,'debit_minor'=>$debit,'credit_minor'=>$credit];}
$debit=array_sum(array_column($lines,'debit_minor'));$credit=array_sum(array_column($lines,'credit_minor'));if($debit!==$amount||$credit!==$amount||count($lines)<2||count($lines)>3||$rule->debit_account_id!==$lines[0]['account_id']||$rule->credit_account_id!==$lines[count($lines)-1]['account_id'])throw new \DomainException('Plano do activo não concilia com regra ou montante.');return['contract'=>'SGF-FIXED-ASSETS-INTEGRATED-QA-v1','source'=>$s,'journal_on'=>$date,'currency'=>'MZN','period'=>(array)$period,'rule'=>(array)$rule,'lines'=>$lines,'impact'=>['debit_minor'=>$amount,'credit_minor'=>$amount,'financial_balance_changed'=>false,'payment_created'=>false]];
}
}
+3 -2
View File
@@ -51,7 +51,7 @@ trait LedgerCycle
$hash=$this->hash([$action,$d]);$old=$this->q('commands')->where(['actor_id'=>$this->actor,'command_key'=>$key])->first();
if($old){if(!hash_equals($old->request_hash,$hash))throw new \DomainException('Chave repetida com conteúdo diferente.');return json_decode($old->result,true,128,JSON_THROW_ON_ERROR)+['replayed'=>true];}
if($action==='effect-prepare'){
if(!in_array($d['effect_type'],['allocation.confirmed','allocation.reversed','sales.invoice','receivable.allocated','receivable.reversed','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return'],true))throw new \InvalidArgumentException('Seleccione uma origem contabilística suportada.');
if(!in_array($d['effect_type'],['allocation.confirmed','allocation.reversed','sales.invoice','receivable.allocated','receivable.reversed','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return','assets.admitted','assets.depreciation','assets.writeoff'],true))throw new \InvalidArgumentException('Seleccione uma origem contabilística suportada.');
$result=$this->prepare(['obligation_id'=>$d['source_id']]+$d,$reason,$d['effect_type']);
}elseif(str_starts_with($action,'correct-'))$result=$this->correction($action,$d,$reason);
else $result=$this->periodCommand($action,$d,$reason);
@@ -65,8 +65,9 @@ trait LedgerCycle
$id=$this->id($d['journal_id']);
if($this->q('correction_results')->where('original_journal_id',$id)->exists())throw new \DomainException('Correcção já aplicada; consulte o par existente.');
$original=$this->q('journals')->where('id',$id)->first()??throw new \DomainException('Original fora do âmbito.');$isCost=json_decode($original->snapshot,true)['source']['effect_type']==='workshop.direct_cost.validated';$isIndustrial=json_decode($original->snapshot,true)['source']['effect_type']==='industry.cost.validated';
$isAsset=json_decode($original->snapshot,true)['source']['effect_type']==='assets.depreciation';
$parts=[];
foreach($isIndustrial?['industry.cost.reversal'=>'QA-INDUSTRIAL-CORRECTION-REVERSE','industry.cost.replacement'=>'QA-INDUSTRIAL-CORRECTION-REPLACE']:($isCost?['workshop.direct_cost.reversal'=>'QA-COST-CORRECTION-REVERSE','workshop.direct_cost.replacement'=>'QA-COST-CORRECTION-REPLACE']:['journal.reversal'=>'QA-CORRECTION-REVERSE','journal.replacement'=>'QA-CORRECTION-REPLACE']) as $kind=>$rule){
foreach($isAsset?['assets.depreciation.reversal'=>'QA-ASSET-CORRECTION-REVERSE','assets.depreciation.replacement'=>'QA-ASSET-CORRECTION-REPLACE']:($isIndustrial?['industry.cost.reversal'=>'QA-INDUSTRIAL-CORRECTION-REVERSE','industry.cost.replacement'=>'QA-INDUSTRIAL-CORRECTION-REPLACE']:($isCost?['workshop.direct_cost.reversal'=>'QA-COST-CORRECTION-REVERSE','workshop.direct_cost.replacement'=>'QA-COST-CORRECTION-REPLACE']:['journal.reversal'=>'QA-CORRECTION-REVERSE','journal.replacement'=>'QA-CORRECTION-REPLACE'])) as $kind=>$rule){
$parts[]=$this->prepare(['obligation_id'=>$id,'journal_on'=>$d['journal_on'],'period_key'=>$d['period_key'],'rule_key'=>$rule],$reason,$kind);
}
$revision=$this->hash($parts);
+11 -8
View File
@@ -8,6 +8,7 @@ use Tempo\Finance\Payables\Payables;
final class PayableJournal
{
use LedgerCycle;
use LedgerAssets;
use LedgerSales;
use LedgerReports;
public const POLICY='QA-PAYABLE-JOURNAL-v1';
@@ -37,8 +38,9 @@ final class PayableJournal
if(!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D',$date,$m)||!checkdate((int)$m[2],(int)$m[3],(int)$m[1]))throw new \InvalidArgumentException('Data de lançamento explícita inválida.');
$sales=in_array($kind,['sales.invoice','receivable.allocated','receivable.reversed','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed'],true);$cost=in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true);
$industrial=in_array($kind,['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'],true);
$asset=in_array($kind,['assets.admitted','assets.depreciation','assets.writeoff','assets.depreciation.reversal','assets.depreciation.replacement'],true);
$stock=in_array($kind,['stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return'],true);
$s=$sales||$cost||$industrial||$stock?($this->additionalSource!==null?($this->additionalSource)($kind,$obligation):throw new \DomainException('Origem adicional não composta neste host.')):($kind==='supplier_payable.recognised'?($this->source)($obligation):$this->cycleSource($kind,$obligation));
$s=$sales||$cost||$industrial||$stock||$asset?($this->additionalSource!==null?($this->additionalSource)($kind,$obligation):throw new \DomainException('Origem adicional não composta neste host.')):($kind==='supplier_payable.recognised'?($this->source)($obligation):$this->cycleSource($kind,$obligation));
$amount=$s['amount_minor']??$s['principal_minor']??null;
if(($s['effect_type']??'')!==$kind||!($s['compatible']??false)||($s['currency']??'')!=='MZN'||!is_int($amount)||$amount<=0||(!$sales&&($s['tax_minor']??null)!==0)||($s['history_imported']??true))throw new \DomainException('Fonte incompatível com o ensaio contabilístico.');
$period=$this->latest('periods','period_key',$periodKey);$rule=$this->latest('rules','rule_key',$ruleKey);
@@ -47,6 +49,7 @@ final class PayableJournal
if($stock){if(!$this->db->getSchemaBuilder()->hasTable('gl_stock_books')||($s['stock_movement_on']??'')!==$date)throw new \DomainException('Valorização não composta ou data física incompatível.');}
if($industrial&&($s['period_start']<$period->starts_on||$s['period_end']>$period->ends_on))throw new \DomainException('Período não contém o lote seleccionado.');
if($period->status!=='open'||$date<$period->starts_on||$date>$period->ends_on)throw new \DomainException('Período fechado, incompatível ou alterado.');
if($asset)return $this->captureAssets($s,$date,$period,$rule,$amount);
if($rule->policy!==(str_starts_with($kind,'cash.')?'QA-CASH-JOURNAL-v1':($stock?'QA-STOCK-VALUATION-v1':($sales?'QA-SALES-JOURNAL-v1':($cost?'QA-DIRECT-COST-JOURNAL-v1':($industrial?'QA-INDUSTRIAL-JOURNAL-v1':self::POLICY)))))||$rule->effect_type!==$s['effect_type']||$rule->currency!=='MZN'||$date<$rule->valid_from||$date>$rule->valid_to)throw new \DomainException('Regra contabilística ausente, incompatível ou fora de vigência.');
if(str_starts_with($kind,'cash.')&&($s['permitted_period_key']!==$periodKey||$s['cash_effect_on']!==$date))throw new \DomainException('Data ou período de Caixa incompatível.');if($sales)return $this->captureSales($s,$date,$period,$rule,$amount);
$accounts=$this->q('accounts')->whereIn('id',[$rule->debit_account_id,$rule->credit_account_id])->get()->keyBy('id');
@@ -83,7 +86,7 @@ final class PayableJournal
if($old){if(!hash_equals($old->request_hash,$hash))throw new \DomainException('Chave repetida com conteúdo diferente.');return json_decode($old->result,true,64,JSON_THROW_ON_ERROR)+['replayed'=>true];}
if(in_array($action,['validate','post'],true)){
$candidate=$this->proposal($this->id($data['proposal_id']));
if(in_array(json_decode($candidate->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement'],true))throw new \DomainException('Use a decisão/aplicação do par de correcção completo.');
if(in_array(json_decode($candidate->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement','assets.depreciation.reversal','assets.depreciation.replacement'],true))throw new \DomainException('Use a decisão/aplicação do par de correcção completo.');
}
if($action==='prepare')$result=$this->prepare($data,$reason);
elseif($action==='validate')$result=$this->review($data,$reason);
@@ -107,7 +110,7 @@ final class PayableJournal
{
if(!is_string($d['revision'])||!in_array($d['decision'],['approved','rejected'],true))throw new \InvalidArgumentException('Decisão/revisão explícita inválida.');
$p=$this->proposal($this->id($d['proposal_id']));
if(in_array(json_decode($p->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement'],true))($this->authorize)('correct_validate');
if(in_array(json_decode($p->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement','assets.depreciation.reversal','assets.depreciation.replacement'],true))($this->authorize)('correct_validate');
if((int)$p->prepared_by===$this->actor||$p->prepared_person===$this->person)throw new \DomainException('Revisão exige outra identidade e pessoa.');
$this->current($p,$d['revision']);$old=$this->q('reviews')->where('proposal_id',$p->id)->first();
if($old){if($old->decision!==$d['decision']||$old->reason!==$reason)throw new \DomainException('Decisão já registada; não pode ser alterada.');return ['state'=>$old->decision,'proposal_id'=>$p->id,'review_id'=>$old->id];}
@@ -118,7 +121,7 @@ final class PayableJournal
private function post(array $d,string $reason):array
{
if(!is_string($d['revision']))throw new \InvalidArgumentException('Revisão inválida.');$p=$this->proposal($this->id($d['proposal_id']));
if(in_array(json_decode($p->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement'],true))($this->authorize)('correct_post');
if(in_array(json_decode($p->snapshot,true)['source']['effect_type'],['journal.reversal','journal.replacement','workshop.direct_cost.reversal','workshop.direct_cost.replacement','industry.cost.reversal','industry.cost.replacement','assets.depreciation.reversal','assets.depreciation.replacement'],true))($this->authorize)('correct_post');
if((int)$p->prepared_by!==$this->actor||$p->prepared_person!==$this->person)throw new \DomainException('Só o preparador autorizado aplica esta versão.');
$existing=$this->q('journals')->where('effect_key',$p->effect_key)->first();
if($existing){if((int)$existing->proposal_id!==(int)$p->id||!hash_equals($p->revision,$d['revision'])||$existing->reason!==$reason)throw new \DomainException('Efeito já lançado com conteúdo diferente.');return ['state'=>'posted','proposal_id'=>$p->id,'journal_id'=>$existing->id];}
@@ -153,12 +156,12 @@ final class PayableJournal
$lines=$this->q('lines')->whereIn('journal_id',$ids)->orderBy('line_no')->get();
$totals=$this->q('lines')->whereIn('journal_id',(clone $journals)->select('id'));if($account)$totals->where('account_id',$account->id);
$sum=$totals->selectRaw('coalesce(sum(debit_minor),0) AS debit_minor,coalesce(sum(credit_minor),0) AS credit_minor')->first();
$proposalQuery=$this->q('proposals');if($industrialLot)$proposalQuery->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']);if($costOrder)$proposalQuery->where('snapshot->source->cost_order_id',$costOrder);if($salesInvoice)$proposalQuery->where('snapshot->source->invoice_id',$salesInvoice);if($focus)$proposalQuery->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS);$proposals=$proposalQuery->orderByDesc('id')->paginate(25,['*'],'page',$page);$proposalIds=array_map(fn($p)=>$p->id,$proposals->items());$reviews=$this->q('reviews')->whereIn('proposal_id',$proposalIds)->get();
$proposalQuery=$this->q('proposals');if($industrialLot)$proposalQuery->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement','assets.depreciation.reversal','assets.depreciation.replacement']);if($costOrder)$proposalQuery->where('snapshot->source->cost_order_id',$costOrder);if($salesInvoice)$proposalQuery->where('snapshot->source->invoice_id',$salesInvoice);if($focus)$proposalQuery->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS);$proposals=$proposalQuery->orderByDesc('id')->paginate(25,['*'],'page',$page);$proposalIds=array_map(fn($p)=>$p->id,$proposals->items());$reviews=$this->q('reviews')->whereIn('proposal_id',$proposalIds)->get();
$posted=$this->q('journals')->whereIn('proposal_id',$proposalIds)->pluck('id','proposal_id');foreach($proposals->items() as $p)$p->journal_id=$posted->get($p->id);
$eventPager=$this->q('events')->when($focus||$salesInvoice||$costOrder||$industrialLot,fn($q)=>$q->where(function($events)use($focus,$salesInvoice,$costOrder,$industrialLot){
$events->where(fn($e)=>$e->where('kind','journal.prepared')->whereIn('entity_id',$this->q('proposals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']))->select('id')))
->orWhere(fn($e)=>$e->where('kind','journal.reviewed')->whereIn('entity_id',$this->q('reviews')->whereIn('proposal_id',$this->q('proposals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']))->select('id'))->select('id')))
->orWhere(fn($e)=>$e->where('kind','journal.posted')->whereIn('entity_id',$this->q('journals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement']))->select('id')));
$events->where(fn($e)=>$e->where('kind','journal.prepared')->whereIn('entity_id',$this->q('proposals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement','assets.depreciation.reversal','assets.depreciation.replacement']))->select('id')))
->orWhere(fn($e)=>$e->where('kind','journal.reviewed')->whereIn('entity_id',$this->q('reviews')->whereIn('proposal_id',$this->q('proposals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement','assets.depreciation.reversal','assets.depreciation.replacement']))->select('id'))->select('id')))
->orWhere(fn($e)=>$e->where('kind','journal.posted')->whereIn('entity_id',$this->q('journals')->when($focus,fn($q)=>$q->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS))->when($salesInvoice,fn($q)=>$q->where('snapshot->source->invoice_id',$salesInvoice))->when($costOrder,fn($q)=>$q->where('snapshot->source->cost_order_id',$costOrder))->when($industrialLot,fn($q)=>$q->where('snapshot->source->industrial_lot_id',$industrialLot)->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement','assets.depreciation.reversal','assets.depreciation.replacement']))->select('id')));
}))->orderByDesc('id')->paginate(25,['*'],'page',$page);
return ['synthetic_only'=>true,'journals'=>$pager->items(),'lines'=>$lines->all(),'proposals'=>$proposals->items(),'reviews'=>$reviews->all(),'accounts'=>$withAccounts?$this->q('accounts')->orderBy('id')->get()->all():[],
'events'=>$eventPager->items(),'totals'=>['debit_minor'=>(int)$sum->debit_minor,'credit_minor'=>(int)$sum->credit_minor,'net_minor'=>(int)$sum->debit_minor-(int)$sum->credit_minor],
+1
View File
@@ -0,0 +1 @@
.multi-invoice-layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:20px;align-items:start}.multi-invoice-totals{position:sticky;top:12px;margin-top:0}.multi-invoice-totals dl{display:grid;grid-template-columns:1fr auto;gap:12px}.multi-invoice-totals dd{margin:0;text-align:right;font-variant-numeric:tabular-nums}.multi-total{font-size:1.35rem;font-weight:700}.multi-line-fields{display:grid;grid-template-columns:minmax(160px,2fr) 90px minmax(100px,1fr) minmax(120px,1fr) auto;gap:12px;align-items:end}.multi-reference{min-width:0;margin:12px 0}.multi-reference .actions{display:flex;flex-wrap:wrap;gap:8px}.multi-reference select{width:100%;max-width:100%}.multi-invoice-workspace fieldset{min-width:0}.multi-invoice-workspace nav{display:flex;gap:12px;align-items:center;margin-top:14px}.multi-invoice-workspace .multi-line-fields button{margin-bottom:0}.multi-invoice-workspace .multi-invoice-totals{overflow-wrap:anywhere}@media(max-width:1000px){.multi-invoice-layout{grid-template-columns:minmax(0,1fr)}.multi-invoice-totals{position:static}.multi-line-fields{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.multi-line-fields{grid-template-columns:minmax(0,1fr)}}
+38
View File
@@ -0,0 +1,38 @@
import React,{useEffect,useState,useRef} from 'react';
const kinds={asset:'Cadastro e custódia',plan:'Planos mensais',charge:'Quotas por competência',disposal:'Abate sem contraprestação'};
const fields={asset:{code:'Código QA',description:'Descrição',origin_reference:'Referência da abertura artificial',cost:'Custo MZN (desconhecido se vazio)',residual:'Residual MZN (desconhecido se vazio)',available_on:'Entrada em uso',location:'Localização interna',custodian:'Custódia interna'},plan:{asset_key:'Activo',life_months:'Vida artificial em meses',status:'Estado do plano'},charge:{asset_key:'Activo',plan_key:'Plano escolhido',month:'Competência'},disposal:{asset_key:'Activo',effect_on:'Data do abate'}};
const money=v=>v===null||v===undefined?'Indeterminado':`${BigInt(v)/100n},${String(BigInt(v)%100n).padStart(2,'0')} MZN`;
const label=r=>r?.data?.input?.code??r?.data?.input?.month??`${kinds[r?.kind]??'Registo'} · v${r?.version??''}`;
export default function FixedAssetsWorkspace({api,context,can,perform,busy}){
const[kind,setKind]=useState('asset'),[page,setPage]=useState(1),[size,setSize]=useState(25),[search,setSearch]=useState(''),[list,setList]=useState(null),[selected,setSelected]=useState(null),[form,setForm]=useState({}),[reason,setReason]=useState(''),[cut,setCut]=useState(null),[key,setKey]=useState(crypto.randomUUID()),[domain,setDomain]=useState(''),[result,setResult]=useState(null),[error,setError]=useState(''),[ledger,setLedger]=useState(null),[ledgerPage,setLedgerPage]=useState(1),[history,setHistory]=useState(false);
const reads=useRef({list:0,ledger:0});
const locked=busy||Boolean(cut);const unit=context.unit?.id;const chosenAsset=selected?(selected.kind==='asset'?selected.record_key:selected.data.input.asset_key):null;
async function load(){const q=new URLSearchParams({kind,page,per_page:size,...(search?{search}:{}),...(history&&selected?{record_key:selected.record_key}:{})});const n=++reads.current.list;const out=await api('/api/fixed-assets?'+q);if(n===reads.current.list){if(out.kind!==kind||out.records.per_page!==Number(size)||!Array.isArray(out.records.data)||out.records.data.length>size)throw Error('Resposta de consulta incompatível; actualize.');setList(out);}}
async function loadLedger(){const n=++reads.current.ledger;if(chosenAsset){const out=await api('/api/fixed-assets/ledger?'+new URLSearchParams({asset_key:chosenAsset,page:ledgerPage,per_page:25}));if(n===reads.current.ledger)setLedger(out);}else setLedger(null);}
useEffect(()=>{setList(null);load().catch(e=>setError(e.message));},[context.token,kind,page,size,search,history]);
useEffect(()=>{loadLedger().catch(e=>setError(e.message));},[chosenAsset,ledgerPage]);
function reset(k=kind){reads.current.list++;reads.current.ledger++;setKind(k);setSelected(null);setForm({operation:'establish',status:'active'});setHistory(false);setPage(1);setCut(null);setResult(null);setError('');setLedger(null);setKey(crypto.randomUUID());}
function choose(r){reads.current.ledger++;setLedger(null);setSelected(r);setForm({...((r.effective??r).data.input),operation:'amend'});setCut(null);setResult(null);setLedgerPage(1);setKey(crypto.randomUUID());}
async function preview(data,type='admin'){await perform(async()=>{setError('');setResult(null);setDomain(type);const out=await api('/api/fixed-assets'+(type==='ledger'?'/ledger':'')+'/preview','POST',data,context.token,key);setCut(out);},'Confira o impacto individual antes de confirmar.');}
async function confirm(){await perform(async()=>{try{const out=await api('/api/fixed-assets'+(domain==='ledger'?'/ledger':'')+'/confirm','POST',{preview:cut.preview});setResult({...out,command_key:key});setCut(null);setKey(crypto.randomUUID());await load();await loadLedger();setSelected(null);setForm({});}catch(e){setError(e.message);throw e;}},'Decisão registada e auditada.');}
async function recover(){await perform(async()=>{const out=await api(domain==='ledger'?'/api/general-ledger/receipts/'+key:'/api/fixed-assets/receipts/'+key);setResult(out.result??out);setCut(null);await load();await loadLedger();},'Comprovativo consultado sem repetir a operação.');}
const action=data=>preview({action:'effect-prepare',data},'ledger');
const source=selected?.effective??selected;const input=source?.data.input;const proposedAsset=source?.kind==='asset'?source.record_key:input?.asset_key;
return <section className="fiscal-workspace"><p className="eyebrow">CONTABILIDADE</p><h1>Activos e depreciações internas QA</h1><p className="fiscal-notice">Exercício artificial. Cadastro, plano, quota e lançamento exigem decisões próprias. Sem aquisição, pagamento ou tratamento fiscal legal presumidos.</p>{error&&<p role="alert">{error}</p>}
<div className="actions">{Object.entries(kinds).map(([k,n])=><button key={k} disabled={locked} aria-pressed={kind===k} onClick={()=>reset(k)}>{n}</button>)}</div>
<section className="panel"><h2>{kinds[kind]}</h2><label>Pesquisar<input type="search" disabled={locked} value={search} onChange={e=>{setSearch(e.target.value);setPage(1);}}/></label><label>Registos por página<select disabled={locked} value={size} onChange={e=>{setSize(Number(e.target.value));setPage(1);}}><option>25</option><option>100</option></select></label><button disabled={locked} onClick={()=>perform(load,'Lista actualizada.')}>Actualizar</button><button disabled={locked||!can('fixed_assets.prepare',unit)} onClick={()=>{reset();setForm({operation:'establish',status:'active'});}}>Novo registo</button>
{list?.records.data.map(r=><article key={r.id}><button disabled={locked} onClick={()=>choose(r)}>{label(r)} · v{r.version} · {r.state==='prepared'?'Aguarda revisão':r.state==='approved'?'Revisto':'Recusado'}</button><p>{r.data.input.description??r.data.input.evidence}</p>{r.effective&&<p>Versão efectiva: {r.effective.version}</p>}</article>)}
{list&&<nav aria-label="Páginas dos activos"><button disabled={locked||page<=1} onClick={()=>setPage(page-1)}>Anterior</button><span>Página {page} de {list.records.last_page} · {list.records.total} registos</span><button disabled={locked||page>=list.records.last_page} onClick={()=>setPage(page+1)}>Seguinte</button></nav>}</section>
{selected&&<section className="panel"><h2>Versão seleccionada</h2><p>{label(selected)} · versão {selected.version} · autor #{selected.actor_id} · {selected.created_at}</p><p>{selected.reason}</p><button disabled={locked} onClick={()=>{setHistory(!history);setPage(1);}}>{history?'Voltar aos registos actuais':'Consultar histórico desta origem'}</button><dl>{Object.entries(selected.data.input).filter(([k])=>fields[kind][k]).map(([k,v])=><React.Fragment key={k}><dt>{fields[kind][k]}</dt><dd>{v===null?'Indeterminado':String(v)}</dd></React.Fragment>)}</dl>{selected.data.calculation?.complete&&<p>Quota: {money(selected.data.calculation.quota_cents)} · líquido previsto: {money(selected.data.calculation.carrying_cents)}</p>}</section>}
{can('fixed_assets.prepare',unit)&&<section className="panel"><h2>{selected?'Preparar nova versão':'Preparar registo'}</h2><form onSubmit={e=>{e.preventDefault();const d={operation:form.operation??'establish',evidence:form.evidence??''};for(const f of Object.keys(fields[kind]))d[f]=form[f]===''||form[f]===undefined?null:f==='life_months'?Number(form[f]):form[f];const recordKey=selected?.record_key??crypto.randomUUID();preview({action:'prepare',kind,record_key:recordKey,version:selected?.version??0,reason,data:d});}}><fieldset disabled={locked}>
<label>Operação<select value={form.operation??'establish'} onChange={e=>setForm({...form,operation:e.target.value})}><option value="establish">Criar</option><option value="amend">Alterar explicitamente</option>{kind==='plan'&&<option value="correct_plan">Plano para correcção de quota contabilizada</option>}{kind!=='plan'&&<option value="cancel">Cancelar sem apagar (se admissível)</option>}</select></label>
{Object.entries(fields[kind]).map(([f,n])=><label key={f}>{n}{f==='status'?<select value={form[f]??'active'} onChange={e=>setForm({...form,[f]:e.target.value})}><option value="active">Activo</option><option value="suspended">Suspenso</option></select>:['asset_key','plan_key'].includes(f)?<ReferencePicker api={api} kind={f==='asset_key'?'asset':'plan'} value={form[f]??''} onChange={v=>setForm({...form,[f]:v})}/>:<input type={f==='month'?'month':f.endsWith('_on')?'date':f==='life_months'?'number':'text'} min={f==='life_months'?1:undefined} max={f==='life_months'?600:undefined} value={form[f]??''} onChange={e=>setForm({...form,[f]:e.target.value})}/>}</label>)}<label>Evidência<textarea required minLength={10} maxLength={1000} value={form.evidence??''} onChange={e=>setForm({...form,evidence:e.target.value})}/></label><label>Fundamento da decisão<textarea required minLength={10} maxLength={2000} value={reason} onChange={e=>setReason(e.target.value)}/></label><button>Conferir preparação</button></fieldset></form></section>}
{selected?.state==='prepared'&&can('fixed_assets.review',unit)&&<section className="panel"><h2>Revisão independente</h2><label>Fundamento<textarea disabled={locked} minLength={10} maxLength={2000} value={reason} onChange={e=>setReason(e.target.value)}/></label>{['approved','refused'].map(decision=><button key={decision} disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'review',kind,record_key:selected.record_key,version:selected.version,reason,data:{decision}})}>{decision==='approved'?'Conferir aprovação':'Conferir recusa'}</button>)}</section>}
{source?.state==='approved'&&source.data.status!=='cancelled'&&source.kind!=='plan'&&can('general_ledger.prepare',unit)&&<section className="panel"><h2>Origem revista → diário comum</h2><label>Fundamento contabilístico<textarea disabled={locked} minLength={10} value={reason} onChange={e=>setReason(e.target.value)}/></label><button disabled={locked||reason.trim().length<10} onClick={()=>{const type={asset:'assets.admitted',charge:'assets.depreciation',disposal:'assets.writeoff'}[source.kind];const date=source.kind==='asset'?input.available_on:source.kind==='charge'?new Date(Date.UTC(Number(input.month.slice(0,4)),Number(input.month.slice(5,7)),0)).toISOString().slice(0,10):input.effect_on;action({effect_type:type,source_id:source.id,journal_on:date,period_key:'QA-2026-INTEGRADO',rule_key:'QA-ASSET-'+({asset:'ADMISSION',charge:'DEPRECIATION',disposal:ledger?.balance.accumulated_minor===0?'WRITEOFF-NO-DEP':'WRITEOFF'}[source.kind]),reason});}}>Conferir proposta contabilística desta origem</button><p>Preparar não aplica o lançamento. O diário exige outra revisão e aplicação explícita.</p></section>}
{ledger&&<section className="panel"><h2>Diário do activo seleccionado</h2><p>Custo {money(ledger.balance.cost_minor)} · acumulado {money(ledger.balance.accumulated_minor)} · líquido {money(ledger.balance.carrying_minor)} · {ledger.balance.disposed?'Abatido':'Sem abate aplicado'}</p><a href="/general-ledger">Abrir diário, balancete, razão e período</a>{ledger.records.data.map(p=><article key={p.id}><h3>Proposta #{p.id} · {p.snapshot.source.effect_type}</h3><p>{p.snapshot.journal_on} · {money(p.snapshot.impact.debit_minor)} · {p.journal?'Lançamento #'+p.journal.id:p.review?'Revista':'Aguarda revisão'}</p><JournalLines rows={p.snapshot.lines}/>{!p.journal&&!p.snapshot.source.effect_type.endsWith('.reversal')&&!p.snapshot.source.effect_type.endsWith('.replacement')&&<><label>Fundamento<textarea disabled={locked} value={reason} onChange={e=>setReason(e.target.value)}/></label>{!p.review&&can('general_ledger.validate',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'validate',data:{proposal_id:p.id,revision:p.revision,decision:'approved',reason}},'ledger')}>Conferir revisão contabilística</button>}{p.review?.decision==='approved'&&can('general_ledger.post',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'post',data:{proposal_id:p.id,revision:p.revision,reason}},'ledger')}>Conferir aplicação no diário</button>}</>}{p.journal&&p.snapshot.source.effect_type==='assets.depreciation'&&can('general_ledger.correct_prepare',unit)&&<><label>Data explícita da correcção<input disabled={locked} type="date" value={form.correction_on??''} onChange={e=>setForm({...form,correction_on:e.target.value})}/></label><button disabled={locked||!form.correction_on||reason.trim().length<10} onClick={()=>preview({action:'correct-prepare',data:{journal_id:p.journal.id,journal_on:form.correction_on,period_key:'QA-2026-INTEGRADO',reason}},'ledger')}>Conferir estorno e substituição</button></>}</article>)}{ledger.corrections?.map(g=><article key={'group'+g.id}><h3>Par de correcção #{g.id}</h3><p>{g.applied?'Aplicado integralmente':g.approved?'Revisto distintamente':'Aguarda revisão distinta'}</p>{!g.applied&&<><label>Fundamento do par<textarea disabled={locked} value={reason} onChange={e=>setReason(e.target.value)}/></label>{!g.approved&&can('general_ledger.correct_validate',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'correct-validate',data:{correction_id:g.id,revision:g.revision,decision:'approved',reason}},'ledger')}>Conferir revisão do par</button>}{g.approved&&can('general_ledger.correct_post',unit)&&<button disabled={locked||reason.trim().length<10} onClick={()=>preview({action:'correct-post',data:{correction_id:g.id,revision:g.revision,reason}},'ledger')}>Conferir aplicação indivisível do par</button>}</>}</article>)}<button disabled={locked||ledgerPage<=1} onClick={()=>setLedgerPage(ledgerPage-1)}>Anterior</button><span>Página {ledgerPage} de {ledger.records.last_page}</span><button disabled={locked||ledgerPage>=ledger.records.last_page} onClick={()=>setLedgerPage(ledgerPage+1)}>Seguinte</button></section>}
{cut&&<section className="panel fiscal-review"><h2>Impacto antes da confirmação</h2>{domain==='admin'?<><p>Versão {cut.impact.before.version} → {cut.impact.after.version} · {cut.impact.after.state}</p><p>{cut.impact.after.proposal.input.evidence}</p>{cut.impact.after.proposal.calculation?.complete&&<p>Quota {money(cut.impact.after.proposal.calculation.quota_cents)} · líquido previsto {money(cut.impact.after.proposal.calculation.carrying_cents)}</p>}<p>Esta decisão administrativa cria zero lançamentos e zero pagamentos.</p></>:<>{cut.impact.snapshots.map((s,i)=><article key={i}><h3>{s.source.effect_type}</h3><p>{s.journal_on} · {money(s.impact.debit_minor)}</p><JournalLines rows={s.lines}/></article>)}<p>Estado proposto: {cut.impact.state} · lançamentos nesta operação: {cut.impact.journal_count}</p></>}<button disabled={busy} onClick={confirm}>Confirmar só este impacto</button><button disabled={busy} onClick={()=>{setCut(null);setKey(crypto.randomUUID());}}>Voltar à conferência</button><button disabled={busy} onClick={recover}>Recuperar comprovativo desta operação</button></section>}
{result&&<section className="panel"><h2>Comprovativo da operação</h2><p>Estado: {result.state} · {result.version?'versão '+result.version:''} · {result.journal_id?'lançamento #'+result.journal_id:''}</p><p>Operação {result.command_key??key}. O histórico conserva autor, data, fundamento e estados.</p></section>}
</section>;
}
function JournalLines({rows}){return <table><thead><tr><th>Conta QA</th><th>Débito</th><th>Crédito</th></tr></thead><tbody>{rows.map((l,i)=><tr key={i}><td>{l.account_code}</td><td>{money(l.debit_minor)}</td><td>{money(l.credit_minor)}</td></tr>)}</tbody></table>;}
function ReferencePicker({api,kind,value,onChange}){const[q,setQ]=useState(''),[page,setPage]=useState(1),[data,setData]=useState(null),[error,setError]=useState('');useEffect(()=>{let live=true;api('/api/fixed-assets?'+new URLSearchParams({kind,search:q,page,per_page:25})).then(v=>{if(live){setData(v.records);setError('');}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[kind,q,page]);return <><input aria-label="Pesquisar referência" value={q} onChange={e=>{setQ(e.target.value);setPage(1);}}/><select value={value} onChange={e=>onChange(e.target.value)}><option value="">Escolher explicitamente</option>{value&&!data?.data.some(r=>r.record_key===value)&&<option value={value}>Escolha conservada · {value}</option>}{data?.data.filter(r=>r.effective&&r.effective.data.status!=='cancelled').map(r=><option key={r.record_key} value={r.record_key}>{label(r.effective)} · versão {r.effective.version}</option>)}</select>{error&&<span role="alert">{error}</span>}<button type="button" disabled={page<=1} onClick={()=>setPage(page-1)}>Anteriores</button><button type="button" disabled={!data||page>=data.last_page} onClick={()=>setPage(page+1)}>Seguintes</button></>;}
@@ -0,0 +1,49 @@
import React,{useEffect,useMemo,useRef,useState}from'react';
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
const base='/api/multiline-invoices',policy='SGF-MULTI-LINE-INVOICE-INTEGRATED-QA-v1';
const fresh=()=>({draft_id:null,version:0,source_version:0,preparation_version:0,name:'',client_id:'',client_name:'',currency:'MZN',price_mode:'',service_date:'',period_start:'',period_end:'',proposed_due_date:'',payment_terms:'',lines:[{service_id:'',service_name:'',description:'',quantity:'',unit_price:'',unit:''}],reason:''});
export function checkedInvoiceOptions(v,kind,page,size){if(v.kind!==kind||v.records?.current_page!==page||v.records.per_page!==size||!Array.isArray(v.records.data)||v.records.data.length>size)throw Error('Resposta de outra consulta/página. Actualize.');return v.records;}
const money=v=>v===null||v===undefined?'A conferir':`${String(v).replace('.',',')} MZN`;
export function InvoiceTotals({result,arithmetic=null,mode}){return <aside className="multi-invoice-totals panel" aria-label="Totais do documento"><h2>Totais do cálculo seleccionado</h2><dl><dt>Base documental</dt><dd>{money(result?.base_document)}</dd><dt>Imposto documental</dt><dd>{money(result?.tax_document)}</dd><dt>Total</dt><dd className="multi-total">{money(result?.total)}</dd></dl>{!result&&<p>{arithmetic!==null?`${mode==='included'?'Bruto':'Base aritmética'} proposta: ${arithmetic} MZN. `:''}Escolha e confira a regra aplicável; imposto desconhecido não é zero.</p>}{result&&<p>Valores documentais das linhas somados. {result.price_mode==='gross_included'?'Preço com imposto incluído.':'Preço sem imposto; imposto acrescido.'}</p>}</aside>;}
export function InvoiceReferencePicker({api,kind,value,label,onChange,disabled=false,draftId=null,filter}){
const[q,setQ]=useState({page:1,search:''}),[search,setSearch]=useState(''),[data,setData]=useState(null),[error,setError]=useState('');useEffect(()=>{let live=true;if(disabled)return;setData(null);api(base+'/options?'+new URLSearchParams({kind,page:q.page,per_page:25,...(q.search?{search:q.search}:{}),...(draftId?{draft_id:draftId}:{})})).then(v=>{if(live){setData(checkedInvoiceOptions(v,kind,q.page,25));setError('');}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[q,kind,draftId,disabled]);const rows=(data?.data??[]).filter(filter??(()=>true));return <fieldset disabled={disabled} className="multi-reference"><legend>{label}</legend><div className="actions" data-sgf-read-filters="true"><input aria-label={'Pesquisar '+label} value={search} onChange={e=>setSearch(e.target.value)}/><button type="button" onClick={()=>setQ({page:1,search})}>Pesquisar</button></div><select aria-label={label} value={value??''} onChange={e=>onChange(e.target.value,rows.find(r=>String(r.id)===e.target.value))}><option value="">Escolher explicitamente…</option>{value&&!rows.some(r=>String(r.id)===String(value))&&<option value={value}>Escolha conservada #{value}</option>}{rows.map(r=><option key={r.id} value={r.id}>{r.name??r.identifier??r.rule_key} {r.valid_from?`· ${r.valid_from} a ${r.valid_until??'por definir'}`:''}{r.rule_version?' · v'+r.rule_version:r.version?' · v'+r.version:''}</option>)}</select>{error&&<p role="alert">{error}</p>}{data&&<div className="actions"><button type="button" disabled={q.page<=1} onClick={()=>setQ({...q,page:q.page-1})}>Anteriores</button><span>Página {q.page}/{data.last_page}</span><button type="button" disabled={q.page>=data.last_page} onClick={()=>setQ({...q,page:q.page+1})}>Seguintes</button></div>}</fieldset>;
}
export default function MultiLineInvoiceWorkspace({api,context,can,navigate,markClean=()=>{},actor}){
const[query,setQuery]=useState({page:1,per_page:25,search:''}),[search,setSearch]=useState(''),[list,setList]=useState(null),[frame,setFrame]=useState(null),[form,setForm]=useState(fresh),[dirty,setDirty]=useState(false),[intentDirty,setIntentDirty]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[cut,setCut]=useState(null),[key,setKey]=useState(crypto.randomUUID()),[selectedRule,setSelectedRule]=useState(''),[calculations,setCalculations]=useState([]),[selectedCalc,setSelectedCalc]=useState(null),[temporals,setTemporals]=useState([]),[selectedTemporal,setSelectedTemporal]=useState(null),[series,setSeries]=useState(''),[decisionReason,setDecisionReason]=useState(''),[windowDates,setWindowDates]=useState({from:'',to:''}),[receipt,setReceipt]=useState(null);
const op=useMemo(()=>new WorkshopStockOperation(api),[context.token]),formRef=useRef(null),rootRef=useRef(null),reads=useRef({list:0,editor:0,extras:0});const unit=context.unit?.id,locked=busy||op.uncertain||Boolean(cut),edit=can('service_drafts.'+(frame?'edit':'create'),unit)&&can('billing_drafts.'+(frame?'edit':'create'),unit)&&can('draft_documents.edit',unit)&&!frame?.invoice;
const protect=locked||dirty||intentDirty;
const scopedResult=frame?.invoice?.totals??(!dirty&&frame&&selectedCalc&&selectedCalc.draft_version===frame?.billing.version&&selectedCalc?.preparation_version===frame?.preparation?.version?selectedCalc.result:null);
async function load(){const n=++reads.current.list;const v=await api(base+'/options?'+new URLSearchParams({kind:'drafts',...query}));if(n===reads.current.list)setList(checkedInvoiceOptions(v,'drafts',query.page,query.per_page));}
async function extras(id){const n=++reads.current.extras;const paths=[api(base+'/options?kind=calculations&draft_id='+id+'&page=1&per_page=100'),can('service_invoices.view',unit)?api(base+'/options?kind=temporals&draft_id='+id+'&page=1&per_page=100'):Promise.resolve(null)];const results=await Promise.all(paths);if(n!==reads.current.extras)return;setCalculations(checkedInvoiceOptions(results[0],'calculations',1,100).data);if(results[1])setTemporals(checkedInvoiceOptions(results[1],'temporals',1,100).data);return{calculations:results[0].records.data,temporals:results[1]?.records.data??[]};}
async function open(id){const n=++reads.current.editor;setError('');const v=await api(base+'/drafts/'+id);if(n!==reads.current.editor)return;if(v.policy!==policy||v.billing.id!==id)throw Error('Resposta de outra origem.');const p=v.preparation?.data??{};setFrame(v);setForm({draft_id:id,version:v.billing.version,source_version:v.source.version,preparation_version:v.preparation?.version??0,name:v.billing.name,client_id:v.billing.client_id,client_name:v.billing.data.client_name,currency:v.billing.data.currency??'',price_mode:p.price_mode==='undefined'?'':p.price_mode??'',service_date:p.service_date??'',period_start:p.period_start??'',period_end:p.period_end??'',proposed_due_date:p.proposed_due_date??'',payment_terms:p.payment_terms??'',lines:v.billing.data.lines.map((l,i)=>({...l,unit:p.units?.[i]??''})),reason:''});setDirty(false);setIntentDirty(false);setDecisionReason('');setSelectedRule('');setSeries('');setCut(null);setSelectedCalc(null);setSelectedTemporal(null);setReceipt(null);setKey(crypto.randomUUID());setWindowDates({from:v.server_date??'',to:v.server_date??''});markClean(rootRef.current);await extras(id);}
useEffect(()=>{load().catch(e=>setError(e.message));},[query,context.token]);
async function work(fn){if(busy)return;setBusy(true);setError('');try{await fn();}catch(e){setError(e.message);}finally{setBusy(false);}}
function change(k,v){setForm(f=>({...f,[k]:v}));setDirty(true);setCut(null);setSelectedCalc(null);setSelectedTemporal(null);setReceipt(null);}
function line(i,k,v){setForm(f=>({...f,lines:f.lines.map((l,n)=>n===i?{...l,[k]:v}:l)}));setDirty(true);setSelectedCalc(null);setSelectedTemporal(null);setReceipt(null);}
function reset(){reads.current.editor++;reads.current.extras++;setFrame(null);setForm(fresh());setDirty(false);setIntentDirty(false);setDecisionReason('');setSelectedRule('');setCut(null);setSelectedCalc(null);setSelectedTemporal(null);setCalculations([]);setTemporals([]);setReceipt(null);setSeries('');setKey(crypto.randomUUID());markClean(rootRef.current);}
async function previewDraft(e){e.preventDefault();await work(async()=>{const data={...form};delete data.client_name;data.price_mode=data.price_mode||'undefined';for(const f of['period_start','period_end'])data[f]=data[f]||null;data.client_id=Number(data.client_id);data.lines=data.lines.map(({service_id,description,quantity,unit_price,unit})=>({service_id:Number(service_id),description:description||null,quantity:quantity===''?null:quantity,unit_price:unit_price===''?null:unit_price,unit:unit||null}));for(const k of['service_date','proposed_due_date','payment_terms'])data[k]=data[k]||null;const v=await api(base+'/drafts/preview','POST',data,context.token,key);setCut({domain:'drafts',...v});});}
async function previewCalculation(){await work(async()=>{const body={draft_id:frame.billing.id,draft_version:frame.billing.version,preparation_version:frame.preparation.version,rule_event_id:Number(selectedRule),reason:decisionReason};const v=await api(base+'/calculations/preview','POST',body,context.token,key);setCut({domain:'calculations',...v});});}
async function complete(result,domain){result={...result,command_key:op.operation?.key};setIntentDirty(false);setReceipt(result);setCut(null);setKey(crypto.randomUUID());if(domain==='drafts'){await open(result.id);setReceipt(result);await load();setNotice('Rascunho e preparação guardados atomicamente; sem emissão.');}else if(domain==='calculations'){await extras(frame.billing.id);if(result.draft_id!==frame.billing.id)throw Error('Comprovativo de outro rascunho.');setSelectedCalc(result);setNotice('Cálculo preservado. A emissão ainda exige revisão temporal e confirmação.');}else if(domain==='issue'){const id=frame.billing.id;await open(id);setReceipt(result);setNotice('Documento de teste emitido uma vez. Recebível e Contabilidade mantêm as suas decisões.');}else{const next=await extras(frame.billing.id);setSelectedTemporal(next?.temporals.find(t=>t.id===result.id)??null);setNotice('Decisão temporal registada. Seleccione explicitamente a versão actual.');}markClean(rootRef.current);}
async function confirm(){await work(async()=>{const c=cut;if(c.domain==='issue'){const result=await op.send(base+'/drafts/'+frame.billing.id+'/issue',c.body,key);await complete(result,'issue');}else{const result=await op.send(base+'/'+c.domain+'/confirm',{preview:c.preview},key);await complete(result,c.domain);}});}
async function recover(){await work(async()=>{const result=await op.retry();const domain=op.operation.path.endsWith('/issue')?'issue':op.operation.path.includes('/drafts/confirm')?'drafts':op.operation.path.includes('/calculations/confirm')?'calculations':'temporal';await complete(result,domain);});}
async function prepareTemporal(){await work(async()=>{const data={calculation_policy:policy,common_calculation_id:selectedCalc.id,common_rule_event_id:selectedCalc.rule_event_id??Number(selectedRule),draft_id:frame.billing.id,draft_version:frame.billing.version,preparation_version:frame.preparation.version,valid_from:windowDates.from,valid_until:windowDates.to,period_start:form.period_start,period_end:form.period_end,issue_window_start:windowDates.from,issue_window_end:windowDates.to,event:'on_issue_within_deadline',readiness_basis:decisionReason,evidence:decisionReason};const body={kind:'temporal',identifier:selectedTemporal?.identifier??'MULTI-DRAFT-'+frame.billing.id,...(selectedTemporal?{previous_id:selectedTemporal.id}:{}),data};const result=await op.send(base+'/temporals',body,crypto.randomUUID());await complete(result,'temporal');});}
async function reviewTemporal(decision){await work(async()=>{const result=await op.send(base+'/temporals/'+selectedTemporal.id+'/decision',{version:selectedTemporal.version,decision,reason:decisionReason},crypto.randomUUID());await complete(result,'temporal');});}
async function readiness(){await work(async()=>{const r=await api(base+'/drafts/'+frame.billing.id+'/readiness','POST',{series_id:Number(series),rule_id:selectedTemporal.id});if(!r.ready){setError(r.payload.blocks.join(' '));return;}setKey(crypto.randomUUID());setCut({domain:'issue',impact:r.payload,body:{series_id:Number(series),rule_id:selectedTemporal.id,review_hash:r.review_hash}});});}
return <section className="multi-invoice-workspace" ref={rootRef} data-sgf-operation-uncertain={protect?'true':undefined}><p className="eyebrow">COMERCIAL · FACTURAÇÃO</p><h1>Documento com várias linhas</h1><p className="fiscal-notice"><strong>TESTE — SEM VALIDADE FISCAL.</strong> Guardar, calcular, rever e emitir são decisões distintas. Sem recebimento ou lançamento automático.</p>{error&&<p role="alert" className="alert error">{error}</p>}{notice&&<p role="status">{notice}</p>}
<section className="panel"><h2>Rascunhos deste percurso</h2><form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setQuery({...query,page:1,search});}}><div className="actions"><label>Pesquisar documento<input disabled={locked} type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Por página<select disabled={locked} value={query.per_page} onChange={e=>setQuery({...query,page:1,per_page:Number(e.target.value)})}><option>25</option><option>100</option></select></label><button disabled={locked}>Pesquisar</button><button type="button" disabled={locked||dirty||intentDirty} onClick={reset}>Novo documento</button></div></form>{list?.data.map(d=><button key={d.id} disabled={locked||dirty||intentDirty} onClick={()=>work(()=>open(d.id))}>{d.name} · v{d.version}</button>)}{list&&<nav aria-label="Páginas dos documentos"><button disabled={locked||query.page<=1} onClick={()=>setQuery({...query,page:query.page-1})}>Anterior</button><span>Página {list.current_page}/{list.last_page}</span><button disabled={locked||query.page>=list.last_page} onClick={()=>setQuery({...query,page:query.page+1})}>Seguinte</button></nav>}</section>
<div className="multi-invoice-layout"><div><form ref={formRef} onSubmit={previewDraft}><fieldset disabled={locked||!edit}><legend>{frame?'Rascunho #'+frame.billing.id+' · versão '+frame.billing.version:'Novo rascunho'}</legend><section className="panel"><h2>Cabeçalho</h2><div className="form-grid"><label>Designação<input required value={form.name} onChange={e=>change('name',e.target.value)} maxLength={255}/></label><label>Moeda<select required value={form.currency} onChange={e=>change('currency',e.target.value)}><option value="MZN">MZN</option></select></label><label>Modalidade de preço<select value={form.price_mode} onChange={e=>change('price_mode',e.target.value)}><option value="">Escolher…</option><option value="added">Sem imposto, acrescido</option><option value="included">Com imposto incluído</option></select></label></div><InvoiceReferencePicker api={api} kind="clients" value={form.client_id} label="Cliente" disabled={locked||!edit} onChange={(v,r)=>{change('client_id',v);setForm(f=>({...f,client_name:r?.name??''}));}}/>{frame?.parties?.client&&String(form.client_id)===String(frame.billing.client_id)&&<p>NUIT: {frame.parties.client.data.nuit??'Por preencher'} · Morada: {frame.parties.client.data.address??'Por preencher'}</p>}<div className="form-grid">{[['service_date','Data do serviço'],['period_start','Período desde'],['period_end','Período até'],['proposed_due_date','Vencimento proposto']].map(([f,l])=><label key={f}>{l}<input type="date" value={form[f]} onChange={e=>change(f,e.target.value)}/></label>)}</div><label>Condições de pagamento<textarea value={form.payment_terms} onChange={e=>change('payment_terms',e.target.value)} maxLength={1000}/></label></section>
<section className="panel"><h2>Linhas do documento</h2>{form.lines.map((l,i)=><fieldset key={i}><legend>Linha {i+1}</legend><InvoiceReferencePicker api={api} kind="services" value={l.service_id} label={'Serviço da linha '+(i+1)} disabled={locked||!edit} onChange={(v,r)=>{line(i,'service_id',v);setForm(f=>({...f,lines:f.lines.map((x,n)=>n===i?{...x,service_name:r?.name??''}:x)}));}}/><div className="multi-line-fields"><label>Descrição<input value={l.description??''} onChange={e=>line(i,'description',e.target.value)} maxLength={2000}/></label><label>Unidade<input value={l.unit??''} onChange={e=>line(i,'unit',e.target.value)} maxLength={80}/></label><label>Quantidade<input inputMode="decimal" value={l.quantity??''} onChange={e=>line(i,'quantity',e.target.value)}/></label><label>Preço {form.price_mode==='included'?'com imposto':'sem imposto'}<input inputMode="decimal" value={l.unit_price??''} onChange={e=>line(i,'unit_price',e.target.value)}/></label><button type="button" disabled={form.lines.length<=1} onClick={()=>change('lines',form.lines.filter((_,n)=>n!==i))}>Retirar linha</button></div></fieldset>)}<button type="button" disabled={form.lines.length>=25} onClick={()=>change('lines',[...form.lines,{service_id:'',service_name:'',description:'',quantity:'',unit_price:'',unit:''}])}>Adicionar linha</button><p>{form.lines.length} de25 linhas. Preços e quantidades em decimais exactos, com ponto.</p><label>Fundamento da preparação<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>change('reason',e.target.value)}/></label><div className="actions"><button className="primary">Conferir rascunho e preparação</button><button type="button" onClick={()=>{if(frame)work(()=>open(frame.billing.id));else reset();}}>Descartar alterações locais</button></div></section></fieldset></form>{!edit&&<p className="help">Consulta da origem. A alteração exige os direitos próprios de preparação; documentos emitidos conservam a fotografia.</p>}
{frame?.invoice&&<section className="panel"><h2>{frame.invoice.series_code} / {frame.invoice.number}</h2><a href={'/api/service-invoices/'+frame.invoice.id+'/pdf'}>Descarregar o PDF preservado</a><button disabled={locked} onClick={()=>work(async()=>{const d=await api('/api/service-invoices/'+frame.invoice.id);setReceipt({...d,type:'invoice'});})}>Consultar fotografia e histórico</button>{can('service_receivables.view',unit)&&<button disabled={locked} onClick={()=>navigate('service_receivables')}>Continuar no Financeiro</button>}{can('general_ledger.view',unit)&&<button disabled={locked} onClick={()=>navigate('accounting_panel')}>Consultar o diário</button>}</section>}
{intentDirty&&!cut&&!op.uncertain&&<button disabled={busy} onClick={()=>{setDecisionReason('');setSelectedRule('');setSelectedCalc(null);setSelectedTemporal(null);setSeries('');setIntentDirty(false);if(!dirty)markClean(rootRef.current);}}>Descartar conferência local</button>}
{frame&&!dirty&&!frame.invoice&&<section className="panel"><h2>Cálculo e conferência temporal</h2><p>O preparador fiscal conserva um cálculo vinculado a estas linhas. A regra temporal exige outro revisor antes da emissão.</p>{can('fiscal_rules.prepare',unit)&&<><InvoiceReferencePicker api={api} kind="rules" value={selectedRule} label="Regra QA revista e vigente" disabled={locked} onChange={v=>{setSelectedRule(v);setIntentDirty(true);}}/><label>Fundamento fiscal/documental<textarea disabled={locked} minLength={10} value={decisionReason} onChange={e=>{setDecisionReason(e.target.value);setIntentDirty(true);}}/></label><button disabled={locked||!selectedRule||decisionReason.trim().length<10} onClick={previewCalculation}>Conferir cálculo destas linhas</button></>}
<InvoiceReferencePicker api={api} kind="calculations" value={selectedCalc?.id??''} draftId={frame.billing.id} label="Cálculo preservado destas linhas" disabled={locked} onChange={(v,r)=>{setSelectedCalc(r??null);setIntentDirty(true);}}/>{selectedCalc&&<CalculationLines result={selectedCalc.result}/>}
{can('service_fiscal.propose')&&selectedCalc&&<><div className="form-grid"><label>Janela fundamentada desde<input disabled={locked} type="date" value={windowDates.from} onChange={e=>{setWindowDates({...windowDates,from:e.target.value});setIntentDirty(true);}}/></label><label>Janela fundamentada até<input disabled={locked} type="date" value={windowDates.to} onChange={e=>{setWindowDates({...windowDates,to:e.target.value});setIntentDirty(true);}}/></label></div><label>Fundamento da prontidão<textarea disabled={locked} value={decisionReason} minLength={10} onChange={e=>{setDecisionReason(e.target.value);setIntentDirty(true);}}/></label><button disabled={locked||!windowDates.from||!windowDates.to||decisionReason.trim().length<10} onClick={prepareTemporal}>{selectedTemporal?'Preparar nova versão temporal':'Preparar regra temporal desta origem'}</button></>}
<InvoiceReferencePicker api={api} kind="temporals" value={selectedTemporal?.id??''} draftId={frame.billing.id} label="Versão temporal" disabled={locked} onChange={(v,r)=>{setSelectedTemporal(r??null);setIntentDirty(true);}}/>{selectedTemporal&&!selectedTemporal.decision&&can('service_fiscal.approve')&&selectedTemporal.actor_id!==actor&&<><label>Fundamento independente<textarea disabled={locked} value={decisionReason} minLength={10} onChange={e=>{setDecisionReason(e.target.value);setIntentDirty(true);}}/></label>{['approved','refused'].map(d=><button key={d} disabled={locked||decisionReason.trim().length<10} onClick={()=>reviewTemporal(d)}>{d==='approved'?'Aprovar versão temporal':'Recusar versão temporal'}</button>)}</>}
{can('service_invoices.issue',unit)&&<><InvoiceReferencePicker api={api} kind="series" value={series} label="Série de teste revista" disabled={locked} filter={r=>r.decision?.decision==='approved'&&r.data.state==='open'} onChange={v=>{setSeries(v);setIntentDirty(true);}}/><button disabled={locked||!series||selectedTemporal?.decision?.decision!=='approved'} onClick={readiness}>Conferir prontidão e impacto de emissão</button></>}{can('service_invoices.view',unit)&&<button disabled={locked} onClick={()=>navigate('service_invoice_setup')}>Configuração de séries e emissão de teste</button>}</section>}
</div><InvoiceTotals result={scopedResult} mode={form.price_mode}/></div>
{cut&&<section className="panel fiscal-review"><h2>Impacto antes da confirmação</h2>{cut.domain==='drafts'?<><p>{cut.impact.after.name} · {cut.impact.after.client} · {cut.impact.after.lines.length} linhas.</p><p>Montante aritmético proposto: {cut.impact.after.arithmetic_amount??'Indeterminado'} MZN. Sem documento emitido, recebível ou lançamento.</p></>:cut.domain==='calculations'?<CalculationLines result={cut.impact.result}/>:<><p>Cliente: {cut.impact.document.client.name} · Série: {cut.impact.series_code} · Data prevista: {cut.impact.expected_date}</p><InvoiceTotals result={cut.impact.calculation_entry.snapshot.result.common_document_result}/><p>A confirmação atribui um número e fixa o PDF de teste. Recebível e diário exigem decisões posteriores.</p></>}<button className="primary" disabled={busy||op.uncertain} onClick={confirm}>{cut.domain==='issue'?'Confirmar emissão só de teste':'Confirmar este impacto'}</button><button disabled={busy||op.uncertain} onClick={()=>{setCut(null);if(dirty)formRef.current?.dispatchEvent?.(new Event('input',{bubbles:true}));setKey(crypto.randomUUID());}}>Voltar à conferência</button></section>}
{op.uncertain&&<section className="panel" role="alert"><h2>Resultado por recuperar</h2><p>Conserve a mesma operação. A recuperação usa a mesma chave e o mesmo pedido.</p><button disabled={busy} onClick={recover}>Recuperar a mesma operação</button></section>}
{receipt&&<section className="panel"><h2>Comprovativo e evidências</h2><p>Registo #{receipt.id} · {receipt.number?'número '+receipt.number:''} · {receipt.version?'versão '+receipt.version:''}</p>{receipt.document_lines?.map(([k,v],i)=>k==='title'?<h3 key={i}>{v}</h3>:k==='section'?<h4 key={i}>{v}</h4>:<p key={i}>{v}</p>)}{receipt.command_key&&<details><summary>Identificador para recuperar o comprovativo</summary><p>{receipt.command_key}</p></details>}<p>Autor, versões, decisão e fotografia permanecem no histórico da origem.</p></section>}
</section>;
}
export function CalculationLines({result:r}){if(!r)return null;return <><InvoiceTotals result={r}/><details><summary>Conferir valores por linha e diferenças</summary><div className="table-scroll"><table><thead><tr><th>Linha</th><th>Base documental</th><th>Imposto</th><th>Total</th><th>Exactos e diferenças</th></tr></thead><tbody>{r.lines.map(l=><tr key={l.line}><td>{l.line}</td><td>{money(l.base_document)}</td><td>{money(l.tax_document)}</td><td>{money(l.total_document)}</td><td>Base {l.base_exact_fraction} · imposto {l.tax_exact_fraction}<br/>Diferenças {l.base_difference_fraction} / {l.tax_difference_fraction}</td></tr>)}</tbody></table></div></details></>;}
+6 -6
View File
@@ -1,6 +1,6 @@
import React,{useEffect,useMemo,useRef,useState}from'react';
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
import{personnelDownload}from'./statement-download.js';
import{personnelDownload,personnelReadableDownload}from'./statement-download.js';
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
export const personnelKinds={contract:'Contratos',day:'Planos e assiduidade',entitlement:'Condições de férias',leave:'Pedidos e gozo de férias',report:'Conferência do período'};
const labels={prepared:'Preparado — por rever',approved:'Revisto e aprovado',refused:'Recusado',active:'Activo',terminated:'Terminado',cancelled:'Cancelado',reserved:'Reservado',taken:'Gozado'};
@@ -10,8 +10,8 @@ export function PersonnelSourcePicker({kind,workerKey,api,context,value,onChange
const[query,setQuery]=useState({page:1,per_page:25,search:''}),[search,setSearch]=useState(''),[data,setData]=useState(null),[error,setError]=useState('');useEffect(()=>{let active=true;setData(null);setError('');if(kind!=='worker'&&!workerKey)return;const q={...query,kind:kind==='worker'?'worker':kind,...(workerKey?{worker_key:workerKey}:{})},path=kind==='worker'?'/api/remunerations':'/api/hr-personnel';api(path+'?'+qs(q)).then(r=>{if(active)setData(kind==='worker'?r:checkedPersonnel(r,q));}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[query,workerKey,context.token,kind]);const records=data?.records.data??[];
return <fieldset disabled={disabled}><legend>{kind==='worker'?'Colaborador fictício revisto':personnelKinds[kind]+' — fonte revista'}</legend><label>Pesquisar fonte<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>setQuery({page:1,per_page:25,search})}>Pesquisar {kind==='worker'?'colaborador':personnelKinds[kind]}</button>{error&&<p role="alert">{error}</p>}<label>Escolha explícita<select required={!optional} value={value} onChange={e=>{const r=records.find(r=>r.record_key===e.target.value);onChange(e.target.value,r?.effective??r);}}><option value="">{optional?'Sem ligação contratual escolhida':'Escolher…'}</option>{value&&!records.some(r=>r.record_key===value)&&<option value={value}>Fonte escolhida anteriormente</option>}{records.filter(r=>r.effective&&r.effective.data.status!=='cancelled'&&(kind!=='worker'||r.effective.data.status==='active')).map(r=><option key={r.record_key} value={r.record_key}>{r.effective.data.input.code??r.effective.data.input.reference??r.effective.data.input.date??(r.effective.data.input.valid_from+' a '+r.effective.data.input.valid_to)} · v{r.effective.version}</option>)}</select></label>{data&&<><button type="button" disabled={disabled||query.page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior — fontes</button><span>Página{data.records.current_page}/{data.records.last_page}</span><button type="button" disabled={disabled||query.page>=data.records.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte — fontes</button></>}</fieldset>;
}
function Intervals({label,value,onChange,disabled,nullable=false}){return <fieldset disabled={disabled}><legend>{label}</legend>{nullable&&<label><input type="checkbox" checked={value===null} onChange={e=>onChange(e.target.checked?null:[])}/>Informação ainda desconhecida</label>}{value!==null&&<>{value.map((r,i)=><div className="form-grid" key={i}><label>Desde<input required type="time" value={clock(r.from)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,from:minutes(e.target.value)}:x))}/></label><label>Até<input required type={r.to===1440?'text':'time'} value={clock(r.to)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,to:minutes(e.target.value)}:x))}/></label><button type="button" onClick={()=>onChange(value.filter((_,j)=>i!==j))}>Remover intervalo</button></div>)}<button type="button" disabled={disabled||value.length>=8} onClick={()=>onChange([...value,{from:480,to:720}])}>Adicionar intervalo</button>{value.length===0&&<p>Conjunto explicitamente vazio. Não equivale a informação desconhecida.</p>}</>}</fieldset>;}
export function PersonnelResult({kind,data}){const i=data.input,r=data.result;return <section><h3>{personnelKinds[kind]} · {labels[data.status]}</h3><p>{data.dependencies.worker.code} · {i.reference??i.date??i.period??(i.valid_from+' a '+i.valid_to)}.</p>{kind==='contract'&&<dl className="overview-definitions"><dt>Modalidade interna</dt><dd>{{fixed:'Prazo explícito',open:'Sem termo declarado',unknown_end:'Fim desconhecido'}[i.mode]}</dd><dt>Vigência</dt><dd>{i.valid_from} a {i.valid_to??'Sem fim confirmado'}</dd><dt>Bruto mensal declarado</dt><dd>{i.gross} MZN</dd><dt>Dependentes revistos</dt><dd>{r.dependent_records}</dd></dl>}{kind==='day'&&<dl className="overview-definitions"><dt>Previsto</dt><dd>{duration(r.planned_minutes)}</dd><dt>Declarado</dt><dd>{duration(r.declared_minutes)}</dd><dt>Férias conferidas</dt><dd>{duration(r.leave_minutes)}</dd><dt>Por conferir</dt><dd>{duration(r.unaccounted_minutes)}</dd><dt>Fora do plano, sem autorização de horas extra</dt><dd>{duration(r.outside_plan_minutes)}</dd></dl>}{['entitlement','leave'].includes(kind)&&<><p>{kind==='leave'?'Pedido: '+duration(r.minutes)+'. ':''}Saldo do exercício{duration((r.balance_after??r).available_minutes)}, reservado{duration((r.balance_after??r).reserved_minutes)}, gozado{duration((r.balance_after??r).taken_minutes)}.</p><p>Minutos de orçamento fictício; sem direito anual legal presumido.</p></>}{kind==='report'&&<><p>{r.known_days} dias com registos; {r.known_conferred_days} conferidos; {r.other_calendar_days_unknown} outros dias sem cobertura comprovada. Mês completo: não demonstrado.</p><div className="table-scroll"><table><thead><tr><th>Dia / versão</th><th>Previsto</th><th>Declarado</th><th>Férias</th><th>Por conferir</th></tr></thead><tbody>{r.days.map(d=><tr key={d.record_key}><td>{d.calculation.date} · v{d.version}</td><td>{duration(d.calculation.planned_minutes)}</td><td>{duration(d.calculation.declared_minutes)}</td><td>{duration(d.calculation.leave_minutes)}</td><td>{duration(d.calculation.unaccounted_minutes)}</td></tr>)}</tbody></table></div>{r.leave_balances.map(b=><p key={b.entitlement_key}>Condição v{b.entitlement_version}: disponível{duration(b.available_minutes)}, reservado{duration(b.reserved_minutes)}, gozado{duration(b.taken_minutes)}.</p>)}{r.payroll.map(p=><p key={p.record_key}>Processamento v{p.version}: {p.contract_link_proven?'Tem contrato escolhido na origem.':'Sem ligação contratual histórica comprovada.'} O processamento não foi recalculado.</p>)}</>}<p>Evidência: {i.evidence}. Sem salário, dívida ou pagamento automático.</p></section>;}
function Intervals({label,value,onChange,disabled,nullable=false}){return <fieldset disabled={disabled}><legend>{label}</legend>{nullable&&<label><input type="checkbox" checked={value===null} onChange={e=>onChange(e.target.checked?null:[])}/>Informação ainda desconhecida</label>}{value!==null&&<>{value.map((r,i)=><div className="form-grid" key={i}><label>Desde<input required type="time" value={clock(r.from)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,from:minutes(e.target.value)}:x))}/></label><label><input type="checkbox" checked={r.to===1440} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,to:e.target.checked?1440:1020}:x))}/>Fim à meia-noite deste dia</label><label>Até<input readOnly={r.to===1440} required type={r.to===1440?'text':'time'} value={clock(r.to)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,to:minutes(e.target.value)}:x))}/></label><button type="button" onClick={()=>onChange(value.filter((_,j)=>i!==j))}>Remover intervalo</button></div>)}<button type="button" disabled={disabled||value.length>=8} onClick={()=>onChange([...value,{from:480,to:720}])}>Adicionar intervalo</button>{value.length===0&&<p>Conjunto explicitamente vazio. Não equivale a informação desconhecida.</p>}</>}</fieldset>;}
export function PersonnelResult({kind,data}){const i=data.input,r=data.result;return <section><h3>{personnelKinds[kind]} · {labels[data.status]}</h3><p>{data.dependencies.worker.code} · {i.reference??i.date??i.period??(i.valid_from+' a '+i.valid_to)}.</p>{kind==='contract'&&<dl className="overview-definitions"><dt>Modalidade interna</dt><dd>{{fixed:'Prazo explícito',open:'Sem termo declarado',unknown_end:'Fim desconhecido'}[i.mode]}</dd><dt>Vigência</dt><dd>{i.valid_from} a {i.valid_to??'Sem fim confirmado'}</dd><dt>Bruto mensal declarado</dt><dd>{i.gross} MZN</dd><dt>Dependentes revistos</dt><dd>{r.dependent_records}</dd></dl>}{kind==='day'&&<dl className="overview-definitions"><dt>Previsto</dt><dd>{duration(r.planned_minutes)}</dd><dt>Declarado</dt><dd>{duration(r.declared_minutes)}</dd><dt>Férias conferidas</dt><dd>{duration(r.leave_minutes)}</dd><dt>Por conferir</dt><dd>{duration(r.unaccounted_minutes)}</dd><dt>Fora do plano, sem autorização de horas extra</dt><dd>{duration(r.outside_plan_minutes)}</dd></dl>}{['entitlement','leave'].includes(kind)&&<><p>{kind==='leave'?'Pedido: '+duration(r.minutes)+'. ':''}Saldo no resultado desta versão{duration((r.balance_after??r).available_minutes)}, reservado{duration((r.balance_after??r).reserved_minutes)}, gozado{duration((r.balance_after??r).taken_minutes)}.</p><p>Minutos de orçamento fictício; sem direito anual legal presumido.</p></>}{kind==='report'&&<><p>{r.known_days} dias com registos; {r.known_conferred_days} conferidos; {r.other_calendar_days_unknown} outros dias sem cobertura comprovada. Mês completo: não demonstrado.</p><div className="table-scroll"><table><thead><tr><th>Dia / versão</th><th>Previsto</th><th>Declarado</th><th>Férias</th><th>Por conferir</th></tr></thead><tbody>{r.days.map(d=><tr key={d.record_key}><td>{d.calculation.date} · v{d.version}</td><td>{duration(d.calculation.planned_minutes)}</td><td>{duration(d.calculation.declared_minutes)}</td><td>{duration(d.calculation.leave_minutes)}</td><td>{duration(d.calculation.unaccounted_minutes)}</td></tr>)}</tbody></table></div>{r.leave_balances.map(b=><p key={b.entitlement_key}>Condição v{b.entitlement_version}: disponível{duration(b.available_minutes)}, reservado{duration(b.reserved_minutes)}, gozado{duration(b.taken_minutes)}.</p>)}{r.payroll.map(p=><p key={p.record_key}>Processamento v{p.version}: {p.contract_link_proven?'Tem contrato escolhido na origem.':'Sem ligação contratual histórica comprovada.'} O processamento não foi recalculado.</p>)}</>}{data.refusal_conference&&<p>Recusa conferida com as fontes actuais{data.refusal_conference.original_source_changed?' alteradas desde a preparação':''}. A proposta não foi aplicada.{data.refusal_conference.current_balance&&<> Saldo efectivo conferido: disponível{duration(data.refusal_conference.current_balance.available_minutes)}, reservado{duration(data.refusal_conference.current_balance.reserved_minutes)}.</>}</p>}<p>Evidência: {i.evidence}. Sem salário, dívida ou pagamento automático.</p></section>;}
export default function PersonnelWorkspace({api,context,initialKind='contract'}){
const[worker,setWorker]=useState(null),[query,setQuery]=useState({kind:initialKind,page:1,per_page:25}),[filters,setFilters]=useState({search:'',period:'',state:''}),[data,setData]=useState(null),[selected,setSelected]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[receiptKey,setReceiptKey]=useState(''),[receipt,setReceipt]=useState(null);const op=useMemo(()=>new WorkshopStockOperation(api),[context.token]),generation=useRef(context.token),mounted=useRef(true);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);const locked=busy||op.uncertain,protect=locked||Boolean(form);
useEffect(()=>{if(!query.worker_key)return;let active=true;setBusy(true);setError('');setData(null);api('/api/hr-personnel?'+qs(query)).then(r=>{if(active)setData(checkedPersonnel(r,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,query]);
@@ -19,12 +19,12 @@ export default function PersonnelWorkspace({api,context,initialKind='contract'})
const change=(k,v)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[k]:v}}));};
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const body={...form,data:{...form.data}};if(body.kind==='contract'&&body.action==='prepare'&&body.data.mode!=='fixed')body.data.valid_to=null;const key=crypto.randomUUID(),out=await api('/api/hr-personnel/preview','POST',body,undefined,key);setPreview({...out,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await op.retry():await op.send('/api/hr-personnel/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice(`v${out.version} · ${labels[out.state]}${out.replayed?' · recibo recuperado':''}`);setSelected(null);setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!op.uncertain)setPreview(null);}finally{setBusy(false);}}
async function download(row){const token=context.token;setBusy(true);setError('');try{const out=await personnelDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='sgf-rh-interno-'+row.kind+'-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
async function download(row,readable=false){const token=context.token;setBusy(true);setError('');try{const guard=()=>mounted.current&&generation.current===token;const out=readable?await personnelReadableDownload({id:row.id,token,sourceSha256:row.data.artifact_sha256,isCurrent:guard}):await personnelDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:guard});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=(readable?'sgf-rh-resumo-legivel-':'sgf-rh-interno-')+row.kind+'-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
const field=(k,label,type='text',extra={})=><label key={k}>{label}<input required type={type} value={form.data[k]??''} onChange={e=>change(k,e.target.value)} {...extra}/></label>,source=(kind,key)=> <PersonnelSourcePicker kind={kind} workerKey={query.worker_key} context={context} api={api} value={form.data[key]} onChange={v=>change(key,v)} disabled={locked||Boolean(preview)||form.version>0}/>;
return <section className="panel" data-sgf-operation-uncertain={protect?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Contratos, assiduidade e férias QA</h1><p>Fontes e decisões individuais, com revisão distinta. Registos internos fictícios; sem calendário laboral legal presumido nem alteração automática de remunerações.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<PersonnelSourcePicker kind="worker" api={api} context={context} value={query.worker_key??''} onChange={(key,row)=>{setWorker(row??null);setSelected(null);setData(null);setQuery({kind:query.kind,page:1,per_page:25,...(key?{worker_key:key}:{})});}} disabled={protect}/>{worker&&<p>Ficha de {worker.data.input.code}, cadastro revisto v{worker.version}. Consultas abaixo mantêm esta pessoa e o contexto.</p>}<nav aria-label="Funções de pessoal">{Object.entries(personnelKinds).map(([kind,label])=><button key={kind} disabled={protect||!query.worker_key} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,worker_key:query.worker_key,page:1,per_page:25});}}>{label}</button>)}</nav>{!query.worker_key&&<p>Escolha explicitamente um colaborador revisto para consultar ou preparar.</p>}
return <section className="panel" data-sgf-operation-uncertain={protect?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Contratos, assiduidade e férias QA</h1><p>Fontes e decisões individuais, com revisão distinta. Registos internos fictícios; sem calendário laboral legal presumido nem alteração automática de remunerações. Resultados preparados são propostas; reserva e gozo só ficam efectivos após revisão alheia.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<PersonnelSourcePicker kind="worker" api={api} context={context} value={query.worker_key??''} onChange={(key,row)=>{setWorker(row??null);setSelected(null);setData(null);setQuery({kind:query.kind,page:1,per_page:25,...(key?{worker_key:key}:{})});}} disabled={protect}/>{worker&&<p>Ficha de {worker.data.input.code}, cadastro revisto v{worker.version}. Consultas abaixo mantêm esta pessoa e o contexto.</p>}<nav aria-label="Funções de pessoal">{Object.entries(personnelKinds).map(([kind,label])=><button key={kind} disabled={protect||!query.worker_key} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,worker_key:query.worker_key,page:1,per_page:25});}}>{label}</button>)}</nav>{!query.worker_key&&<p>Escolha explicitamente um colaborador revisto para consultar ou preparar.</p>}
{query.worker_key&&<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery(q=>({kind:q.kind,worker_key:q.worker_key,per_page:q.per_page,...filters,page:1}));}}><fieldset disabled={protect}><label>Pesquisar referência/código<input value={filters.search} onChange={e=>setFilters(f=>({...f,search:e.target.value}))}/></label>{['day','leave','report'].includes(query.kind)&&<label>Período<input type="month" value={filters.period} onChange={e=>setFilters(f=>({...f,period:e.target.value}))}/></label>}<label>Estado<select value={filters.state} onChange={e=>setFilters(f=>({...f,state:e.target.value}))}><option value="">Todos</option>{['prepared','approved','refused'].map(s=><option key={s} value={s}>{labels[s]}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>{setSelected(null);setQuery(q=>({...q,per_page:Number(e.target.value),page:1}));}}><option value="25">25</option><option value="100">100</option></select></label><button>Consultar</button></fieldset></form>}
{data&&<>{data.capabilities.prepare&&!query.record_key&&<button disabled={protect} onClick={()=>{setSelected(null);start('establish',false,true);}}>Novo registo — {personnelKinds[query.kind]}</button>}<p>Página{data.records.current_page}/{data.records.last_page} · {data.records.total} registo/versão(s).</p><div className="table-scroll"><table><thead><tr><th>Referência/dia/período</th><th>Versão e decisão</th><th>Estado revisto actual</th><th>Autor e fundamento</th><th>Escolha</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.data.input.reference??r.data.input.date??r.data.input.period??(r.data.input.valid_from+' a '+r.data.input.valid_to)}</td><td>v{r.version} · {labels[r.state]}<small>{r.data.input.operation}</small></td><td>{r.effective?labels[r.effective.data.status]:'Sem aprovação'}{r.effective&&<small>v{r.effective.version}</small>}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={protect} onClick={()=>{setSelected(r);setReceipt(null);}}>Escolher este registo</button></td></tr>)}</tbody></table></div>{!data.records.data.length&&<p>Sem registos neste âmbito/filtro.</p>}<button disabled={protect||query.page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={protect||query.page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button>{query.record_key&&<button disabled={protect} onClick={()=>{setSelected(null);setQuery({kind:query.kind,worker_key:query.worker_key,page:1,per_page:25});}}>Voltar à lista actual</button>}</>}
{selected&&<section><h2>Registo escolhido · v{selected.version} · {labels[selected.state]}</h2><PersonnelResult kind={selected.kind} data={selected.data}/><p>Antes v{selected.impact.before.version}, {labels[selected.impact.before.state]??'Sem registo'}; depois v{selected.impact.after.version}, {labels[selected.state]}.</p><button disabled={protect} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.capabilities.prepare&&selected.effective?.data.status!=='cancelled'&&<><button disabled={protect} onClick={()=>start(selected.effective?'amend':'establish')}>Preparar {selected.effective?'correcção':'nova versão'}</button>{selected.effective&&<button disabled={protect} onClick={()=>start('cancel')}>Preparar cancelamento</button>}{selected.kind==='contract'&&selected.effective&&<button disabled={protect} onClick={()=>start('terminate')}>Preparar terminação</button>}{selected.kind==='leave'&&selected.effective?.data.status==='reserved'&&<button disabled={protect} onClick={()=>start('take')}>Declarar gozo sem automatismo</button>}</>}{!query.record_key&&data.capabilities.review&&selected.state==='prepared'&&<button disabled={protect} onClick={()=>start('',true)}>Rever preparação alheia</button>}{selected.state==='approved'&&<button disabled={protect} onClick={()=>download(selected)}>Descarregar versão conservada</button>}</section>}
{selected&&<section><h2>Registo escolhido · v{selected.version} · {labels[selected.state]}</h2><PersonnelResult kind={selected.kind} data={selected.data}/>{selected.current_balance&&<p><strong>Saldo efectivo nesta consulta:</strong> disponível{duration(selected.current_balance.available_minutes)}, reservado{duration(selected.current_balance.reserved_minutes)}, gozado{duration(selected.current_balance.taken_minutes)}. A versão conservada mantém os valores da sua decisão.</p>}<p>Antes v{selected.impact.before.version}, {labels[selected.impact.before.state]??'Sem registo'}; depois v{selected.impact.after.version}, {labels[selected.state]}.</p><button disabled={protect} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.capabilities.prepare&&selected.effective?.data.status!=='cancelled'&&<><button disabled={protect} onClick={()=>start(selected.effective?'amend':'establish')}>Preparar {selected.effective?'correcção':'nova versão'}</button>{selected.effective&&<button disabled={protect} onClick={()=>start('cancel')}>Preparar cancelamento</button>}{selected.kind==='contract'&&selected.effective&&<button disabled={protect} onClick={()=>start('terminate')}>Preparar terminação</button>}{selected.kind==='leave'&&selected.effective?.data.status==='reserved'&&<button disabled={protect} onClick={()=>start('take')}>Declarar gozo sem automatismo</button>}</>}{!query.record_key&&data.capabilities.review&&selected.state==='prepared'&&<button disabled={protect} onClick={()=>start('',true)}>Rever preparação alheia</button>}{selected.state==='approved'&&<><button disabled={protect} onClick={()=>download(selected,true)}>Descarregar resumo legível desta versão</button><button disabled={protect} onClick={()=>download(selected)}>Descarregar CSV original conservado</button><p>O resumo deriva apenas desta versão revista; mantém o SHA de referência do original e identifica o seu próprio formato.</p></>}</section>}
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='review'?'Revisão distinta':'Preparação individual'} · {personnelKinds[form.kind]} · v{form.version}</h2>{form.action==='review'?<label>Decisão<select required value={form.data.decision} onChange={e=>change('decision',e.target.value)}><option value="">Escolher…</option><option value="approved">Aprovar</option><option value="refused">Recusar</option></select></label>:<><p>Operação: {form.data.operation}. Pessoa e fontes são revalidadas na confirmação.</p><fieldset disabled={['cancel','take'].includes(form.data.operation)}>{form.kind==='contract'?<>{field('reference','Referência interna QA','text',{readOnly:form.version>0,pattern:'QA-[A-Z0-9-]{2,60}'})}<label>Modalidade interna<select value={form.data.mode} onChange={e=>change('mode',e.target.value)}><option value="fixed">Prazo explícito</option><option value="open">Sem termo declarado</option><option value="unknown_end">Fim desconhecido — vigência incompleta</option></select></label>{field('valid_from','Início','date',{readOnly:form.version>0})}{field('valid_to','Fim explícito','date',{required:form.data.mode==='fixed',disabled:form.data.mode!=='fixed'})}{field('gross','Bruto mensal MZN','text',{pattern:'[0-9]{1,9}\\.[0-9]{2}',readOnly:form.data.operation==='terminate'})}</>:form.kind==='report'?field('period','Período da conferência','month',{readOnly:form.version>0}):<>{source('contract','contract_key')}{form.kind==='entitlement'?<>{field('valid_from','Vigência desde','date')}{field('valid_to','Vigência até','date')}<label><input type="checkbox" checked={form.data.minutes===null} onChange={e=>change('minutes',e.target.checked?null:0)}/>Saldo inicial explicitamente desconhecido</label><label>Orçamento QA em minutos<input type="number" min="0" max="527040" required={form.data.minutes!==null} disabled={form.data.minutes===null} value={form.data.minutes??''} onChange={e=>change('minutes',Number(e.target.value))}/></label></>:<>{form.kind==='leave'&&<>{source('entitlement','entitlement_key')}{source('day','day_key')}</>}{field('date','Dia local','date',{readOnly:form.version>0})}</>}</>}</fieldset>{['day','leave'].includes(form.kind)&&field('reference_on','Referência da declaração (não futura)','date')}{form.kind==='day'&&<><Intervals label="Intervalos previstos" value={form.data.planned} nullable onChange={v=>change('planned',v)} disabled={locked||Boolean(preview)||form.data.operation==='cancel'}/><Intervals label="Intervalos declarados" value={form.data.actual} nullable onChange={v=>change('actual',v)} disabled={locked||Boolean(preview)||form.data.operation==='cancel'}/><label><input type="checkbox" checked={form.data.leave_conferred} disabled={form.data.operation==='cancel'} onChange={e=>change('leave_conferred',e.target.checked)}/>Conferir explicitamente férias efectivas deste dia</label></>}{form.kind==='leave'&&<Intervals label="Intervalos do pedido" value={form.data.intervals} onChange={v=>change('intervals',v)} disabled={locked||Boolean(preview)||['cancel','take'].includes(form.data.operation)}/>}<label>Referência da evidência<textarea required minLength={10} maxLength={1000} value={form.data.evidence} onChange={e=>change('evidence',e.target.value)}/></label></>}<label>Fundamento individual<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm(f=>({...f,reason:e.target.value}))}/></label><button>Conferir impacto</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário e prévia</button></form>}
{preview&&<section><h2>Impacto antes da confirmação</h2><p>Antes v{preview.impact.before.version}, {labels[preview.impact.before.state]??'Sem registo'}; depois v{preview.impact.after.version}, {labels[preview.impact.after.state]}.</p><PersonnelResult kind={form.kind} data={preview.impact.after.proposal}/><p>Fundamento: {form.reason}. Sem alteração salarial, dívida, pagamento ou lançamento.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria: <code>{preview.key}</code></p></section>}{op.uncertain&&<><p role="alert">Resultado incerto. Conserve corpo/chave e não inicie outra decisão.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
<h2>Recibo técnico próprio</h2><label>Chave<input disabled={protect} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={protect||!receiptKey} onClick={async()=>{setBusy(true);setError('');try{setReceipt(await api('/api/hr-personnel/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}}>Consultar recibo sem executar</button>{receipt&&<p>v{receipt.result.version} · {labels[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Não foi executada outra operação.</p>}
+2 -2
View File
@@ -17,13 +17,13 @@ export default function RemunerationsWorkspace({api,context}){
const operation=useMemo(()=>new WorkshopStockOperation(api),[context.token]),mounted=useRef(true),generation=useRef(context.token);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);const locked=busy||operation.uncertain,protectedForm=locked||Boolean(form);
useEffect(()=>{let active=true;setBusy(true);setData(null);setError('');api('/api/remunerations?'+qs(query)).then(d=>{if(active)setData(checkedRemunerations(d,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[query,context.token]);
function start(operationName='establish',review=false,newRecord=false){const row=newRecord?null:selected,source=row?.effective?.data.input??row?.data.input,kind=query.kind;const defaults=kind==='worker'?{code:'QA-',status:'active'}:kind==='rules'?{label:'',valid_from:'',valid_to:'',worker_rate:'',employer_rate:'',retention_rate:'',source:''}:{worker_key:'',rules_key:'',period:'',gross:''};setPreview(null);setError('');setForm({kind,action:review?'review':'prepare',record_key:row?.record_key??crypto.randomUUID(),version:row?.version??0,reason:'',data:review?{decision:''}:{...(source??defaults),operation:operationName}});}
const change=(key,value)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[key]:value}}));};
const change=(key,value)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[key]:value,...(key==='worker_key'&&f.data.contract_key?{contract_key:''}:{})}}));};
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input={...form,data:{...form.data}};if(!input.data.contract_key)delete input.data.contract_key;if(input.kind==='rules'&&input.action==='prepare')for(const k of['worker_rate','employer_rate','retention_rate'])if(input.data[k]==='')input.data[k]=null;const key=crypto.randomUUID(),p=await api('/api/remunerations/preview','POST',input,undefined,key);setPreview({...p,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await operation.retry():await operation.send('/api/remunerations/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice('Decisão v'+out.version+' · '+states[out.state]+(out.replayed?' · recibo recuperado':''));setSelected(null);setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!operation.uncertain)setPreview(null);}finally{setBusy(false);}}
async function download(row){const token=context.token;setBusy(true);setError('');try{const out=await remunerationDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='sgf-remuneracao-interna-QA-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
async function recover(){setBusy(true);setError('');try{setReceipt(await api('/api/remunerations/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
const textInput=(key,label,type='text',extra={})=><label key={key}>{label}<input required type={type} value={form.data[key]??''} onChange={e=>change(key,e.target.value)} {...extra}/></label>;
return <section className="panel" data-sgf-operation-uncertain={protectedForm?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Remunerações internas QA</h1><p>Área autónoma, com acessos próprios. Cadastro e regras fictícios, processamento e revisão distinta. Sem salário/dívida/pagamento/lançamento, recibo legal ou submissão. Contratos, assiduidade, férias e conformidade legal continuam pendentes.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
return <section className="panel" data-sgf-operation-uncertain={protectedForm?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Remunerações internas QA</h1><p>Área autónoma, com acessos próprios. Cadastro e regras fictícios, processamento e revisão distinta. Sem salário/dívida/pagamento/lançamento, recibo legal ou submissão. Contratos, assiduidade e férias estão na função própria de RH. A conformidade legal continua pendente.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
<nav aria-label="Funções de Recursos Humanos">{Object.entries(kinds).map(([kind,label])=><button key={kind} disabled={protectedForm} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,page:1,per_page:25});}}>{label}</button>)}</nav>
<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery(q=>({kind:q.kind,per_page:q.per_page,...filters,page:1}));}}><fieldset disabled={protectedForm}><label>Pesquisa por código / regras<input value={filters.search} onChange={e=>setFilters(f=>({...f,search:e.target.value}))}/></label>{query.kind==='payroll'&&<label>Período<input type="month" value={filters.period} onChange={e=>setFilters(f=>({...f,period:e.target.value}))}/></label>}<label>Estado<select value={filters.state} onChange={e=>setFilters(f=>({...f,state:e.target.value}))}><option value="">Todos</option>{Object.entries(states).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>{setSelected(null);setQuery(q=>({...q,per_page:Number(e.target.value),page:1}));}}><option value="25">25</option><option value="100">100</option></select></label><button>Consultar</button></fieldset></form>
{data&&<>{data.capabilities.prepare&&!query.record_key&&<button disabled={protectedForm} onClick={()=>{setSelected(null);start('establish',false,true);}}>Novo: {kinds[query.kind]}</button>}<p>Página{data.records.current_page}/{data.records.last_page} · {data.records.total} versão/registo(s).</p><div className="table-scroll"><table><thead><tr><th>Registo / período</th><th>Versão e decisão</th><th>Resultado revisto actual</th><th>Autor / fundamento</th><th>Escolha</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.data.input.code??r.data.input.label??r.data.dependencies.worker?.data.input.code}<small>{r.data.input.period??''} · {r.record_key}</small></td><td>v{r.version} · {states[r.state]} · {r.data.input.operation}</td><td>{r.effective?<>v{r.effective.version} · {r.effective.data.status}{r.effective.data.calculation&&<small>Líquido interno{remunerationMoney(r.effective.data.calculation.net_cents)}</small>}</>:'Sem aprovação'}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={protectedForm} onClick={()=>{setSelected(r);setReceipt(null);}}>Escolher este registo</button></td></tr>)}</tbody></table></div>{!data.records.data.length&&<p>Sem registos neste âmbito/filtro.</p>}<button disabled={protectedForm||query.page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={protectedForm||query.page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button>{query.record_key&&<button disabled={protectedForm} onClick={()=>{setSelected(null);setQuery({kind:query.kind,page:1,per_page:25});}}>Voltar à lista actual</button>}{query.kind==='payroll'&&!query.record_key&&<p>Somente aprovados activos desta página: {data.page_totals.approved_active_on_page}; bruto{remunerationMoney(data.page_totals.gross_cents)}, líquido{remunerationMoney(data.page_totals.net_cents)}, custo{remunerationMoney(data.page_totals.employer_cost_cents)}. Não são totais do universo nem salários pagos.</p>}</>}
+2 -2
View File
@@ -11,13 +11,13 @@ export function navigationGroups(can, unit, capabilities={}) {
{id:'purchasing',label:'Compras',items:[{id:'purchasing_home',label:'Área de Compras',visible:can('company.view')},item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit),item('supplier_payments','Pagamentos e afectações — ensaio','supplier_payables.view',unit)]},
{id:'stock',label:'Stock',status:'Em preparação',items:[...visibleStockSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.stockCatalogue?[item('stock_catalogue','Artigos e revisão do catálogo','stock.view',unit)]:[])]},
{id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]},
{id:'billing',label:'Facturação',items:visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))},
{id:'billing',label:'Facturação',items:[...(capabilities.multilineInvoices?[item('multiline_invoices','Documento com várias linhas QA','billing_drafts.view',unit)]:[]),...visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))]},
{id:'billing_support',label:'Configuração documental',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('service_invoice_setup','Séries e emissão de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]},
{id:'treasury',label:'Fornecedores',items:[...(capabilities.payablePositions?[item('payable_positions','Posições de fornecedores','supplier_payables.view',unit),item('payable_aging','Mapa de antiguidade — fornecedores','supplier_payables.view',unit)]:[]),item('native_payables','Obrigações, pagamentos e afectações','supplier_payables.view',unit)]},
{id:'receivables',label:'Recebimentos e Caixa',items:[item('receivable_positions','Posições de clientes e vencimentos','service_receivables.view',unit),item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit),item('financial_indicators','Critérios e indicadores QA','financial_analysis.view',unit),item('bank_reconciliation','Conferência de extracto QA','bank_reconciliation.view',unit),item('cash','Caixa — abertura, despesas e fecho QA','cash.view',unit)]},
{id:'workshop',label:'Oficinas',items:[...visibleWorkshopSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true})),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento comercial','workshop_corrections.view',unit)]},
{id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]},
{id:'accounting',label:'Contabilidade e fiscalidade',items:[...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.internalStatements?[item('internal_statements','Demonstrações internas parciais QA','general_ledger.view',unit)]:[]),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view'),item('fiscal_iva','Livro e mapa interno de IVA QA','fiscal_assessments.view',unit),item('fiscal_rules','Regras fiscais versionadas QA','fiscal_rules.view',unit),...(capabilities.fiscalObligations?[item('fiscal_obligations','Obrigações e prazos administrativos QA','fiscal_obligations.view',unit)]:[])]},
{id:'accounting',label:'Contabilidade e fiscalidade',items:[...(capabilities.fixedAssets?[item('fixed_assets','Activos e depreciações internas QA','fixed_assets.view',unit)]:[]),...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.internalStatements?[item('internal_statements','Demonstrações internas parciais QA','general_ledger.view',unit)]:[]),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view'),item('fiscal_iva','Livro e mapa interno de IVA QA','fiscal_assessments.view',unit),item('fiscal_rules','Regras fiscais versionadas QA','fiscal_rules.view',unit),...(capabilities.fiscalObligations?[item('fiscal_obligations','Obrigações e prazos administrativos QA','fiscal_obligations.view',unit)]:[])]},
...(capabilities.remunerations?[{id:'human_resources',label:'Recursos Humanos',items:[item('remunerations','Cadastro e remunerações internas QA','human_resources.remunerations.view',unit),...(capabilities.personnel?[{id:'hr_personnel',label:'Contratos, assiduidade e férias',visible:['contracts','attendance','leave'].every(a=>can('human_resources.'+a+'.view',unit))}]:[])]}]:[]),
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('module_records','Associações de módulos','access.manage'),item('access','Acessos','access.manage')]},
].map(g=>({...g,items:g.items.filter(i=>i.visible)})).filter(g=>g.items.length);
+7 -2
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import '../css/app.css';
import '../css/tempo-theme.css';
import '../css/multiline-invoice.css';
import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
@@ -20,6 +21,8 @@ import ModuleNavigation, {moduleGroups,moduleFunctionAllowed} from './ModuleNavi
import {ContextTransition} from './context-transition.js';
import ModuleRecordsWorkspace from './ModuleRecordsWorkspace.jsx';
import ContextTransitionDialog from './ContextTransitionDialog.jsx';
const MultiLineInvoiceWorkspace=React.lazy(()=>import('./MultiLineInvoiceWorkspace.jsx'));
const FixedAssetsWorkspace=React.lazy(()=>import('./FixedAssetsWorkspace.jsx'));
const PersonnelWorkspace=React.lazy(()=>import('./PersonnelWorkspace.jsx'));
const RemunerationsWorkspace=React.lazy(()=>import('./RemunerationsWorkspace.jsx'));
const InternalStatementsWorkspace=React.lazy(()=>import('./InternalStatementsWorkspace.jsx'));
@@ -63,7 +66,7 @@ function App() {
async function resolveTransition(save) { setTransitionBusy(true);setError(''); try { if(save) await transitions.current.save(); else transitions.current.discard(); const action=pendingTransition;setPendingTransition(null); await action(); } catch(e){setError(e.message);} finally{setTransitionBusy(false);} }
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
const allGroups = navigationGroups(can, context?.unit?.id ?? null,{personnel:context?.hr_personnel_enabled===true,remunerations:context?.remunerations_enabled===true,stockCatalogue:context?.stock?.catalogue===true,payablePositions:context?.single_installation===true,fiscalObligations:context?.fiscal_obligations_enabled===true,internalStatements:context?.internal_statements_enabled===true});
const allGroups = navigationGroups(can, context?.unit?.id ?? null,{multilineInvoices:context?.multiline_invoices_enabled===true,fixedAssets:context?.fixed_assets_enabled===true,personnel:context?.hr_personnel_enabled===true,remunerations:context?.remunerations_enabled===true,stockCatalogue:context?.stock?.catalogue===true,payablePositions:context?.single_installation===true,fiscalObligations:context?.fiscal_obligations_enabled===true,internalStatements:context?.internal_statements_enabled===true});
const groups = [...allGroups.filter(g=>['start','admin'].includes(g.id)), ...moduleGroups(allGroups,context?.module_id)];
const navigate = (id,selection=null) => transition(async () => {
setError('');
@@ -128,7 +131,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" onSubmitCapture={e=>{transitions.current.submitting=e.target;}} onInputCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onChangeCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onClickCapture={e=>{transitions.current.submitting=e.target.closest('form')||e.target.closest('section');const a=e.target.closest('a[href]');if(a&&a.origin===location.origin){e.preventDefault();transition(()=>{location.href=a.href;});}}}><ModuleNavigation groups={allGroups} context={context} page={page} navigate={navigate} openPage={openModulePage} user={state.user} busy={busy||transitionBusy} logout={()=>transition(()=>perform(()=>api('/api/logout','POST'),'Sessão terminada.'))} />{pendingTransition&&<ContextTransitionDialog busy={transitionBusy} error={error} onSave={()=>resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}<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><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></header><main>
return <div className="shell" onSubmitCapture={e=>{transitions.current.submitting=e.target;}} onInputCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&!e.target.closest('[data-sgf-read-filters="true"]')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onChangeCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&!e.target.closest('[data-sgf-read-filters="true"]')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onClickCapture={e=>{transitions.current.submitting=e.target.closest('form')||e.target.closest('section');const a=e.target.closest('a[href]');if(a&&a.origin===location.origin){e.preventDefault();transition(()=>{location.href=a.href;});}}}><ModuleNavigation groups={allGroups} context={context} page={page} navigate={navigate} openPage={openModulePage} user={state.user} busy={busy||transitionBusy} logout={()=>transition(()=>perform(()=>api('/api/logout','POST'),'Sessão terminada.'))} />{pendingTransition&&<ContextTransitionDialog busy={transitionBusy} error={error} onSave={()=>resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}<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><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></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}:${page}`}>
@@ -157,6 +160,8 @@ function App() {
{page === 'supplier_payables' && <section className="panel"><h1>Obrigações a fornecedores</h1><p>Percurso sintético ligado ao núcleo comum. Preparação, validação distinta e consulta, sem pagamentos ou vencimento presumido.</p>{context.single_installation?<button onClick={()=>navigate('native_payables')}>Escolher Financeiro e abrir a obrigação original</button>:<a href="/supplier-payables">Abrir preparação e consulta de obrigações</a>}</section>}
{page === 'supplier_payments' && <section className="panel"><h1>Pagamentos e afectações a fornecedores</h1><p>Ensaio sintético: preparação e confirmação distintas, afectação explícita e saldo canónico. Não executa transferências bancárias.</p>{context.single_installation?<button onClick={()=>navigate('native_payables')}>Escolher Financeiro e continuar pagamentos e afectações</button>:<a href="/supplier-payments">Abrir pagamentos e afectações sintéticas</a>}</section>}
{page === 'industrial_costs' && <IndustrialCostsWorkspace context={context} can={can} api={api} busy={busy} />}
{page==='multiline_invoices'&&<React.Suspense fallback={<p>A abrir facturação…</p>}><MultiLineInvoiceWorkspace key={context.token} context={context} api={api} can={can} navigate={navigate} actor={state.user.id} markClean={element=>{if(element)for(const changed of transitions.current.dirty)if(changed===element||element.contains?.(changed))transitions.current.dirty.delete(changed);}}/></React.Suspense>}
{page==='fixed_assets'&&<React.Suspense fallback={<p>A abrir activos…</p>}><FixedAssetsWorkspace key={context.token} context={context} can={can} api={api} perform={perform} busy={busy}/></React.Suspense>}
{page==='internal_statements'&&<React.Suspense fallback={<p>A abrir demonstrações internas…</p>}><InternalStatementsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
{page==='hr_personnel'&&<React.Suspense fallback={<p>A abrir contratos, assiduidade e férias…</p>}><PersonnelWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
{page==='remunerations'&&<React.Suspense fallback={<p>A abrir Recursos Humanos…</p>}><RemunerationsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
+9
View File
@@ -14,3 +14,12 @@ export const fiscalMapDownload=args=>verifiedCsvDownload({...args,pathForId:id=>
export const remunerationDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/remunerations/exports/'+id});
export const personnelDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/hr-personnel/exports/'+id});
/** Readable view is derived; the original artifact SHA and render format are checked separately. */
export async function personnelReadableDownload({id,token,sourceSha256,isCurrent,fetcher=fetch}){
if(!Number.isSafeInteger(Number(id))||Number(id)<1||!token||!/^[a-f0-9]{64}$/.test(sourceSha256))throw Error('Versão ou fonte indisponível. Consulte novamente.');
const check=()=>{if(!isCurrent())throw Error('O contexto mudou. O resumo anterior foi ignorado.');};check();
const r=await fetcher('/api/hr-personnel/readable/'+Number(id),{credentials:'same-origin',headers:{Accept:'text/csv','X-SGF-Context':token}});check();
if(!r.ok){const problem=await r.json().catch(()=>({})),e=Error(problem.message||'Não foi possível consultar o resumo desta versão.');e.status=r.status;throw e;}
const expected=r.headers.get('x-content-sha256');if(!/^text\/csv(?:;|$)/i.test(r.headers.get('content-type')??'')||r.headers.get('x-source-sha256')!==sourceSha256||r.headers.get('x-sgf-read-format')!=='SGF-HR-READABLE-CSV-v1'||!/^[a-f0-9]{64}$/.test(expected??''))throw Error('O resumo não corresponde à versão conservada.');
const bytes=await r.arrayBuffer();check();const digest=await crypto.subtle.digest('SHA-256',bytes);check();const actual=[...new Uint8Array(digest)].map(v=>v.toString(16).padStart(2,'0')).join('');if(actual!==expected)throw Error('O resumo não confere com o SHA da resposta.');return{bytes,sha256:actual,sourceSha256};
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Read existing, authorized pages. No cross-module queries, writes or invented aggregates.
export const moduleGuides={
human_resources:{label:'Recursos Humanos',purpose:'Acompanhar cadastro fictício e remunerações internas com identidade e direitos próprios.',steps:['Cadastro revisto','Regras artificiais revistas','Processamento individual','Revisão distinta','Mapa conservado'],evidence:'Cada versão conserva fonte, autor, fundamento e resultado explicável; a exportação confere por SHA.',boundary:'Processamento interno QA não é salário pago nem cálculo legal IRPS/INSS. Contratos, assiduidade e férias continuam pendentes.',next:'A ligação salarial a Financeiro e Contabilidade ainda não cria dívida, pagamentos ou lançamentos.'},
human_resources:{label:'Recursos Humanos',purpose:'Acompanhar cadastro, contratos, assiduidade, férias e remunerações internas com identidade e direitos próprios.',steps:['Cadastro e contrato revistos','Plano e declaração de tempos','Reserva e gozo de férias','Conferência do período','Remunerações e mapas conservados'],evidence:'Cada versão conserva fonte, autor, fundamento e resultado explicável; a exportação confere por SHA.',boundary:'Processamento interno QA não é salário pago nem cálculo legal IRPS/INSS. O calendário legal e os modelos oficiais permanecem pendentes; dias sem registos não são faltas presumidas.',next:'A ligação salarial a Financeiro e Contabilidade ainda não cria dívida, pagamentos ou lançamentos.'},
commercial:{label:'Comercial',purpose:'Acompanhar clientes, propostas, documentos e a passagem para recebimentos.',steps:['Cliente e serviço','Orçamento e aceitação','Rascunho e revisão','Documento de teste','Recebível e afectação'],evidence:'Número, cliente, versão, emissão e PDF identificam o documento. O recibo conserva a afectação de origem.',boundary:'Orçamento e rascunho não são receita. O saldo de uma factura pode aparecer em vários documentos ligados; não deve ser somado.',next:'A cobrança é acompanhada no Financeiro, conservando a factura de origem.'},
purchasing:{label:'Compras',purpose:'Acompanhar a aquisição desde a necessidade até à recepção e obrigação reconhecida.',steps:['Requisição','Propostas e avaliação','Adjudicação e ordem','Recepção e factura','Obrigação reconhecida'],evidence:'O dossiê mantém decisões, propostas, documentos recebidos, factura conferida e a origem da obrigação.',boundary:'Receber material não comprova pagamento. Uma factura pendente não reserva nem confirma automaticamente uma obrigação.',next:'A recepção alimenta o mesmo Stock; a obrigação segue para conferência financeira individual.'},
stock:{label:'Stock',purpose:'Conhecer o físico por artigo e armazém e rastrear entradas, saídas, trânsito e contagens.',steps:['Artigo e armazém','Recepção ou saída','Transferência e recepção','Lotes e contagem','Conferência da valorização'],evidence:'Cada movimento conserva quantidade, unidade, armazém e origem. Lotes e trânsito têm decisões próprias.',boundary:'Quantidades de unidades diferentes não são somadas. Físico, reservado, bloqueado e disponível não são equivalentes.',next:'Consumos e devoluções da Oficina mantêm a entrega original; valorização é conferida na Contabilidade.'},
+20
View File
@@ -51,10 +51,18 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/fiscal-rules/preview',[\App\Modules\Services\Http\FiscalRulesController::class,'preview']);
Route::post('/api/fiscal-rules/confirm',[\App\Modules\Services\Http\FiscalRulesController::class,'confirm']);
Route::get('/fiscal/iva',[\App\Modules\Services\Http\FiscalAssessmentController::class,'screen']);
Route::get('/api/fixed-assets/ledger',[\App\Modules\Accounting\FixedAssetJournalController::class,'index']);
Route::post('/api/fixed-assets/ledger/preview',[\App\Modules\Accounting\FixedAssetJournalController::class,'preview']);
Route::post('/api/fixed-assets/ledger/confirm',[\App\Modules\Accounting\FixedAssetJournalController::class,'confirm']);
Route::get('/api/fixed-assets',[\App\Modules\Accounting\FixedAssetsController::class,'index']);
Route::post('/api/fixed-assets/preview',[\App\Modules\Accounting\FixedAssetsController::class,'preview']);
Route::post('/api/fixed-assets/confirm',[\App\Modules\Accounting\FixedAssetsController::class,'confirm']);
Route::get('/api/fixed-assets/receipts/{key}',[\App\Modules\Accounting\FixedAssetsController::class,'receipt']);
Route::get('/api/hr-personnel',[\App\Modules\HumanResources\Http\PersonnelController::class,'index']);
Route::post('/api/hr-personnel/preview',[\App\Modules\HumanResources\Http\PersonnelController::class,'preview']);
Route::post('/api/hr-personnel/confirm',[\App\Modules\HumanResources\Http\PersonnelController::class,'confirm']);
Route::get('/api/hr-personnel/receipts/{key}',[\App\Modules\HumanResources\Http\PersonnelController::class,'receipt']);
Route::get('/api/hr-personnel/readable/{id}',[\App\Modules\HumanResources\Http\PersonnelController::class,'readable'])->whereNumber('id');
Route::get('/api/hr-personnel/exports/{id}',[\App\Modules\HumanResources\Http\PersonnelController::class,'export'])->whereNumber('id');
Route::get('/api/remunerations',[\App\Modules\HumanResources\Http\RemunerationsController::class,'index']);
Route::post('/api/remunerations/preview',[\App\Modules\HumanResources\Http\RemunerationsController::class,'preview']);
@@ -262,6 +270,18 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
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/multiline-invoices/options',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'catalogue']);
Route::get('/api/multiline-invoices/drafts/{id}',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'editor'])->whereNumber('id');
Route::post('/api/multiline-invoices/drafts/preview',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'draftPreview']);
Route::post('/api/multiline-invoices/drafts/confirm',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'draftConfirm']);
Route::post('/api/multiline-invoices/calculations/preview',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'calculationPreview']);
Route::post('/api/multiline-invoices/calculations/confirm',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'calculationConfirm']);
Route::get('/api/multiline-invoices/receipts/{key}',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'receipt']);
Route::get('/api/multiline-invoices/invoice-receipts/{key}',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'issueReceipt']);
Route::post('/api/multiline-invoices/temporals',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'temporal']);
Route::post('/api/multiline-invoices/temporals/{id}/decision',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'temporalReview'])->whereNumber('id');
Route::post('/api/multiline-invoices/drafts/{id}/readiness',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'readiness'])->whereNumber('id');
Route::post('/api/multiline-invoices/drafts/{id}/issue',[\App\Modules\Services\Http\MultiLineInvoiceController::class,'issue'])->whereNumber('id');
Route::get('/api/service-invoices', [\App\Modules\Services\Http\ServiceInvoiceController::class,'index']);
Route::get('/commercial-quotes/{key}/print',[\App\Modules\Services\Http\CommercialQuotesController::class,'print']);
Route::get('/api/commercial-quotes',[\App\Modules\Services\Http\CommercialQuotesController::class,'index']);
+2
View File
@@ -0,0 +1,2 @@
<?php
require __DIR__.'/sgf-single-bootstrap.php';use App\Modules\Accounting\AssetDepreciation as A;$checks=[];$ok=function($v,$n)use(&$checks){if(!$v)throw new LogicException($n);$checks[]=$n;};$reject=function($fn,$n)use($ok){try{$fn();throw new LogicException('Expected rejection');}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){$ok($e->getStatusCode()===422,$n);}};$a=A::quota('1200.00','0.00',12,1);$ok($a['quota_cents']==='10000'&&$a['carrying_cents']==='110000','proposed1200/12 first quota100 carrying1100');$b=A::quota('1200.00','0.00',12,12);$ok($b['accumulated_cents']==='120000'&&$b['carrying_cents']==='0','last month exhausts depreciable amount exactly');$one=A::quota('100.00','0.00',3,1);$last=A::quota('100.00','0.00',3,3);$ok($one['quota_cents']==='3333'&&$one['difference_cents']==='1/3'&&$last['quota_cents']==='3334'&&$last['difference_cents']==='-2/3','cent adjustment and signed rational differences preserved');$sum=0;for($i=1;$i<=3;$i++)$sum+=(int)A::quota('100.00','0.00',3,$i)['quota_cents'];$ok($sum===10000,'sum of monthly quotas equals basis');$ok(A::quota('1200.00','200.00',12,12)['carrying_cents']==='20000','residual value preserved');$ok(A::quota('1200.00','1200.00',12,1)['quota_cents']==='0','explicit zero depreciable basis not unknown');foreach([[null,'0.00',12],['1200.00',null,12],['1200.00','0.00',null]]as[$c,$r,$m]){$q=A::quota($c,$r,$m,1);$ok(!$q['complete']&&$q['quota_cents']===null,'missing input remains unknown '.count($checks));}$reject(fn()=>A::quota('100.00','101.00',12,1),'residual greater than cost refused');$reject(fn()=>A::quota('1e3','0.00',12,1),'non documentary amount refused');$reject(fn()=>A::quota('100.00','0.00',12,13),'month beyond declared life refused');$ok($a['legal_tax_treatment']===null,'no legal depreciation rate or tax deduction asserted');file_put_contents($work.'/asset-depreciation-core.json',json_encode(['passed'=>true,'checks'=>$checks,'pure_arithmetic_only'=>true,'new_assets'=>0,'new_grants'=>0,'new_journals'=>0,'installed'=>false],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/asset-depreciation-core.json']).PHP_EOL;
+2
View File
@@ -0,0 +1,2 @@
<?php
$book=$argv[1]??'8085';require __DIR__.'/sgf-single-bootstrap.php';$rights=['fixed_assets.view','fixed_assets.prepare','fixed_assets.review'];$rows=$host->table('permission_grants')->whereIn('permission',$rights)->get(['permission','user_id','tenant_id','company_id','unit_id'])->all();if($rows!==[])throw new LogicException('Reconcile existing asset rights');$old=$host->table('permission_grants')->where(['tenant_id'=>1,'company_id'=>2,'unit_id'=>2])->whereIn('user_id',[15,16,17])->where('permission','like','general_ledger.%')->orderBy('user_id')->orderBy('permission')->get(['user_id','permission'])->all();file_put_contents($work.'/assets-access-readonly.json',json_encode(['passed'=>true,'book'=>$book,'proposed_rights'=>$rights,'existing_asset_grants'=>$rows,'existing_ledger_grants'=>$old,'new_grants'=>0,'database_writes'=>0],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'book'=>$book,'missing_asset_rights'=>3,'proof'=>$work.'/assets-access-readonly.json']).PHP_EOL;
+2
View File
@@ -0,0 +1,2 @@
<?php
$book='8085';require __DIR__.'/sgf-single-bootstrap.php';$prior=json_decode(file_get_contents($s['root'].'/single-integration/20261111-095619-e3fff98c/hp-readable-installation.json'),true);$qualified=array_unique(array_merge($prior['identical_original_tables'],array_map(fn($t)=>'native.'.$t,['gl_accounts','gl_rules','gl_proposals','gl_reviews','gl_journals','gl_lines','gl_commands','gl_events','gl_corrections','gl_correction_results','gl_period_checks','gl_period_reviews','gl_period_closures'])));$before=[];$order=fn($t)=>in_array($t,['sr_outbox','sr_deliveries'],true)?'event_id':(in_array($t,['sc_catalogue_meta','pd_due_meta','fo_meta','fs_meta','rm_meta','hp_meta'],true)?'policy':(in_array($t,['hr_qa_profiles','sgf_presentation_profiles'],true)?'profile_key':(in_array($t,['fs_results','fs_result_lines'],true)?'snapshot_key':($t==='company_memberships'?'user_id':'id'))));foreach($qualified as$k){[$n,$t]=explode('.',$k);$db=$n==='host'?$host:$native;$before[$n][$t]=$db->table($t)->orderBy($order($t))->when($t==='fs_result_lines',fn($q)=>$q->orderBy('account_id'))->get()->toJson();}foreach(['binding.json','single-bindings.json','stock-binding.json']as$f)$before['files'][$f]=hash_file('sha256',$s['root'].'/'.$f);file_put_contents($work.'/preservation-private.json',json_encode($before));$files=['packages/general-ledger/bootstrap.php','packages/general-ledger/src/PayableJournal.php','packages/general-ledger/src/LedgerCycle.php','app/Modules/Accounting/ComposedLedger.php'];foreach($files as$f){$to=$work.'/before/'.$f;if(!is_dir(dirname($to)))mkdir(dirname($to),0700,true);copy($repo.'/'.$f,$to);}file_put_contents($work.'/gl-balance-function-private.sql',$native->selectOne("SELECT pg_get_functiondef('gl_assert_balanced(bigint)'::regprocedure) definition")->definition);file_put_contents($s['root'].'/assets-backup-pointer.json',json_encode(['root'=>$work]));echo json_encode(['passed'=>true,'backup'=>$work,'original_tables'=>count($qualified),'database_writes'=>0]).PHP_EOL;
+19
View File
@@ -0,0 +1,19 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$cls=App\Modules\Accounting\FixedAssets::class;$base=['operation'=>'establish','code'=>'QA-BOUNDARY-ASSET','description'=>'Equipamento fictício de fronteiras','origin_reference'=>'QA-BOUNDARY-OPENING','cost'=>'100.00','residual'=>'0.00','available_on'=>'2026-10-01','location'=>'QA-SALA-A','custodian'=>'QA-CUSTODIA-A','evidence'=>'Dados sintéticos de fronteiras revertidos.'];
$host->beginTransaction();try{
$key=(string)Str::uuid();$in=$faInput('asset',$key,0,$base);$op=(string)Str::uuid();$cut=$faCall(15,'preview',$in,$op)['value'];$ok($host->table('fa_versions')->where('record_key',$key)->count()===0,'preview has no administrative write');$reject(fn()=>$faCall(16,'confirm',['preview'=>$cut['preview']]),403,'preview tied to person');$reject(fn()=>$faCall(15,'confirm',['preview'=>$cut['preview']],null,'other-generation'),409,'preview tied to context generation');$reject(fn()=>$faCall(15,'confirm',['preview'=>'tampered']),409,'tampered preview denied');$out=$faCall(15,'confirm',['preview'=>$cut['preview']])['value'];$again=$faCall(15,'confirm',['preview'=>$cut['preview']])['value'];$ok($again['replayed']&&$again['id']===$out['id']&&$host->table('fa_versions')->where('record_key',$key)->count()===1,'same confirmation one version and receipt');$receipt=$faCall(15,'receipt',[],$op)['value'];$ok($receipt['historical_receipt_only']&&$receipt['result']['id']===$out['id'],'receipt recovers historical decision without execution');$reject(fn()=>$faCall(16,'receipt',[],$op),404,'receipt isolated by actor');$reject(fn()=>$faCall(15,'preview',$in),409,'outdated version denied');
$review=$faInput('asset',$key,1,['decision'=>'approved'],'review');$cut=$faCall(16,'preview',$review)['value'];$host->table('permission_grants')->where($f['scope']+['user_id'=>15,'permission'=>'fixed_assets.prepare'])->delete();$reject(fn()=>$faCall(16,'confirm',['preview'=>$cut['preview']]),403,'preparer rights revalidated at review');
}finally{$host->rollBack();}
$host->beginTransaction();try{
$key=(string)Str::uuid();$in=$faInput('asset',$key,0,$base);$cut=$faCall(15,'preview',$in)['value'];$host->unprepared("CREATE FUNCTION public.fa_test_failure() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'QA_ASSET_RECEIPT_FAILURE'; END \$\$; CREATE TRIGGER fa_test_failure BEFORE INSERT ON fa_commands FOR EACH ROW EXECUTE FUNCTION fa_test_failure()");$failed=false;try{$faCall(15,'confirm',['preview'=>$cut['preview']]);}catch(Illuminate\Database\QueryException$e){$failed=str_contains($e->getMessage(),'QA_ASSET_RECEIPT_FAILURE');}$ok($failed&&$host->table('fa_versions')->where('record_key',$key)->count()===0,'failure between version and receipt rolls back version and audit');$host->statement('DROP TRIGGER fa_test_failure ON fa_commands');$out=$faCall(15,'confirm',['preview'=>$cut['preview']]);$ok($out['status']===200&&$host->table('fa_versions')->where('record_key',$key)->count()===1,'same confirmation recovers after injected failure');
[$ar]=$faCommand(16,$faInput('asset',$key,1,['decision'=>'approved'],'review'));$planKey=(string)Str::uuid();$plan=['operation'=>'establish','asset_key'=>$key,'life_months'=>null,'status'=>'active','evidence'=>'Vida desconhecida explicitamente na regressão.'];$faCommand(15,$faInput('plan',$planKey,0,$plan));$faCommand(16,$faInput('plan',$planKey,1,['decision'=>'approved'],'review'));$chargeKey=(string)Str::uuid();$charge=['operation'=>'establish','asset_key'=>$key,'plan_key'=>$planKey,'month'=>'2026-10','evidence'=>'Quota indeterminada não pode ser aprovada.'];[$cp]=$faCommand(15,$faInput('charge',$chargeKey,0,$charge));$ok($cp['impact']['after']['proposal']['calculation']['quota_cents']===null,'unknown life remains unknown not zero');$reject(fn()=>$faCall(16,'preview',$faInput('charge',$chargeKey,1,['decision'=>'approved'],'review')),409,'unknown quota approval denied');
$life=$plan;$life['operation']='amend';$life['life_months']=12;$faCommand(15,$faInput('plan',$planKey,2,$life));$faCommand(16,$faInput('plan',$planKey,3,['decision'=>'approved'],'review'));$reject(fn()=>$faCall(16,'preview',$faInput('charge',$chargeKey,1,['decision'=>'approved'],'review')),409,'changed dependency demands new preparation');
$suspended=$life;$suspended['status']='suspended';$faCommand(15,$faInput('plan',$planKey,4,$suspended));$faCommand(16,$faInput('plan',$planKey,5,['decision'=>'approved'],'review'));$reject(fn()=>$faCall(15,'preview',$faInput('charge',(string)Str::uuid(),0,$charge)),409,'suspended plan denies quota');$faCommand(15,$faInput('plan',$planKey,6,$life));$faCommand(16,$faInput('plan',$planKey,7,['decision'=>'approved'],'review'));$ok(true,'resume plan separately reviewed');
$amend=$base;$amend['operation']='amend';$amend['location']='QA-SALA-B';$amend['custodian']='QA-CUSTODIA-B';$faCommand(15,$faInput('asset',$key,2,$amend));$x=$faCommand(16,$faInput('asset',$key,3,['decision'=>'approved'],'review'))[0];$ok($x['impact']['after']['proposal']['input']['location']==='QA-SALA-B'&&$x['impact']['journals']===0,'custody change reviewed without stock or GL effect');
for($n=0;$n<27;$n++){$v=$base;$v['code']='QA-PAGE-'.$n;$v['origin_reference']='QA-PAGE-OPEN-'.$n;$faCommand(15,$faInput('asset',(string)Str::uuid(),0,$v));}
$one=$faCall(17,'index',['kind'=>'asset','search'=>'QA-PAGE-','per_page'=>25,'page'=>1])['value']['records'];$two=$faCall(17,'index',['kind'=>'asset','search'=>'QA-PAGE-','per_page'=>25,'page'=>2])['value']['records'];$all=$faCall(17,'index',['kind'=>'asset','search'=>'QA-PAGE-','per_page'=>100])['value']['records'];$ok(count($one['data'])===25&&count($two['data'])===2&&$all['total']===27,'SQL pagination25 and100 filters same27 fixtures');$ok(!array_intersect(array_column($one['data'],'id'),array_column($two['data'],'id')),'no overlap between pages');$literal=$faCall(17,'index',['kind'=>'asset','search'=>'%','per_page'=>25])['value']['records'];$ok($literal['total']===0,'wildcard search treated literally');
$foreign=$base;$foreign['code']='QA-FOREIGN-ASSET';$foreign['origin_reference']='QA-FOREIGN-OPEN';$foreignKey=(string)Str::uuid();$faCommand(15,$faInput('asset',$foreignKey,0,$foreign));$row=$host->table('fa_versions')->where('record_key',$foreignKey)->first();$host->unprepared('ALTER TABLE fa_versions DISABLE TRIGGER fa_versions_immutable');$host->table('fa_versions')->where('id',$row->id)->update(['unit_id'=>1]);$host->unprepared('ALTER TABLE fa_versions ENABLE TRIGGER fa_versions_immutable');$reject(fn()=>$faCall(17,'index',['kind'=>'asset','record_key'=>$foreignKey]),403,'foreign unit record denied even known UUID');
}finally{$host->rollBack();}
$native->beginTransaction();try{$before=$native->table('gl_journals')->count();$native->table('gl_periods')->insert($f['scope']+['period_key'=>'QA-2026-INTEGRADO','version'=>2,'starts_on'=>'2026-01-01','ends_on'=>'2026-12-31','status'=>'closed','created_by'=>15,'reason'=>'Fecho artificial revertido para guarda de período.','created_at'=>now()->utc()]);$p=json_decode(file_get_contents($s['root'].'/assets-regression-progress-private.json'),true);$reject(fn()=>$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>'assets.admitted','source_id'=>$p['steps']['asset_review']['id'],'journal_on'=>'2026-10-01','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-ADMISSION','reason'=>'Período fechado deve recusar sem efeitos.'],(string)Str::uuid())),409,'closed period refuses current source');$ok($native->table('gl_journals')->count()===$before,'closed period refusal creates no journal');}finally{$native->rollBack();}
file_put_contents($work.'/assets-boundaries.json',json_encode(['passed'=>true,'checks'=>$checks,'fixtures_rolled_back'=>true,'cache_cleared'=>false,'new_demo'=>false],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-boundaries.json']).PHP_EOL;
+2
View File
@@ -0,0 +1,2 @@
import fs from 'node:fs';import assert from 'node:assert/strict';import {spawn} from 'node:child_process';
const root=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')).root+'/private/receipt-composition-b4093c6f6707',directory=root+'/assets-concurrency-'+Date.now();fs.mkdirSync(directory);const start=mode=>{const child=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/sgf-assets-concurrency.php',mode,directory],{windowsHide:true});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',v=>output+=v);return{child,done:new Promise(resolve=>child.on('close',code=>resolve({code,output})))};};let result=await start('prepare').done;assert.equal(result.code,0,result.output);const first=start('first');try{const end=Date.now()+8000;while(!fs.existsSync(directory+'/locked.json')&&Date.now()<end)await new Promise(r=>setTimeout(r,30));assert(fs.existsSync(directory+'/locked.json'));const waiting=start('second');await new Promise(r=>setTimeout(r,900));const inspected=await start('inspect').done;assert.equal(inspected.code,0,inspected.output);const second=await waiting.done;assert.equal(second.code,0,second.output);const busy=JSON.parse(fs.readFileSync(directory+'/second.json'));assert.equal(busy.status,503);fs.writeFileSync(directory+'/release','1');result=await first.done;assert.equal(result.code,0,result.output);result=await start('recover').done;assert.equal(result.code,0,result.output);const recovery=JSON.parse(fs.readFileSync(directory+'/recovery.json'));assert(recovery.passed,result.output);const blocking=JSON.parse(fs.readFileSync(directory+'/blocking.json'));assert(blocking.waiters.some(w=>w.wait_event_type==='Lock'));const proof=directory+'/result.json';fs.writeFileSync(proof,JSON.stringify({...recovery,during_lock:503,seconds_waiting:busy.seconds,blocking},null,2));console.log(JSON.stringify({passed:true,proof}));}finally{fs.writeFileSync(directory+'/release','1');await first.done;}
+8
View File
@@ -0,0 +1,8 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$mode=$argv[1]??'';$directory=$argv[2]??'';if($book!=='regression'||!str_starts_with(str_replace('\\','/',$directory),str_replace('\\','/',$s['root']).'/assets-concurrency-'))throw new LogicException('Regression only');
$http=function($token)use($faCall){try{return$faCall(16,'confirm',['preview'=>$token]);}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){return['status'=>$e->getStatusCode()];}};
if($mode==='prepare'){$key=(string)Str::uuid();$a=['operation'=>'establish','code'=>'QA-CONCURRENT-'.strtoupper(substr($key,0,8)),'description'=>'Activo artificial de concurrência sem lançamento','origin_reference'=>'QA-CONCURRENT-OPEN-'.strtoupper(substr($key,0,8)),'cost'=>'1200.00','residual'=>'0.00','available_on'=>'2026-10-01','location'=>'QA-SALA-A','custodian'=>'QA-CUSTODIA-A','evidence'=>'Conferência sintética simultânea do mesmo cadastro.'];$faCommand(15,$faInput('asset',$key,0,$a));$first=$faCall(16,'preview',$faInput('asset',$key,1,['decision'=>'approved'],'review'))['value']['preview'];$second=$faCall(16,'preview',$faInput('asset',$key,1,['decision'=>'refused'],'review'))['value']['preview'];file_put_contents($directory.'/cuts-private.json',json_encode(['first'=>$first,'second'=>$second,'record_key'=>$key]));exit;}
if($mode==='inspect'){$pid=json_decode(file_get_contents($directory.'/locked.json'),true)['host_pid'];$rows=$host->select('SELECT pid,wait_event_type,wait_event,pg_blocking_pids(pid) blockers FROM pg_stat_activity WHERE ? = ANY(pg_blocking_pids(pid))',[$pid]);file_put_contents($directory.'/blocking.json',json_encode(['blocker_host_pid'=>$pid,'waiters'=>$rows,'company_for_update'=>true]));exit;}$cuts=json_decode(file_get_contents($directory.'/cuts-private.json'),true);
if($mode==='first'){$host->beginTransaction();try{$out=$http($cuts['first']);if($out['status']!==200)throw new LogicException('First review failed');file_put_contents($directory.'/locked.json',json_encode(['host_pid'=>$host->selectOne('SELECT pg_backend_pid() pid')->pid]));$end=microtime(true)+25;while(!is_file($directory.'/release')&&microtime(true)<$end)usleep(50000);$host->commit();}finally{while($host->transactionLevel()>0)$host->rollBack();}exit;}
if($mode==='second'){$start=microtime(true);$out=$http($cuts['second']);file_put_contents($directory.'/second.json',json_encode(['status'=>$out['status'],'seconds'=>microtime(true)-$start]));exit;}
if($mode==='recover'){$second=$http($cuts['second']);$replay=$http($cuts['first']);$versions=$host->table('fa_versions')->where('record_key',$cuts['record_key'])->count();$commands=$host->table('fa_commands')->whereRaw("input->>'record_key'=?",[$cuts['record_key']])->count();$out=['passed'=>$second['status']===409&&$replay['status']===200&&$replay['value']['replayed']&&$versions===2&&$commands===2,'after_release'=>$second['status'],'original_replay'=>$replay['status'],'versions'=>$versions,'receipts'=>$commands,'regression_only'=>true];file_put_contents($directory.'/recovery.json',json_encode($out));echo json_encode($out).PHP_EOL;}
+18
View File
@@ -0,0 +1,18 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$progress=$s['root'].'/assets-regression-progress-private.json';$p=json_decode(file_get_contents($progress),true);$save=function()use(&$p,$progress){file_put_contents($progress,json_encode($p));};$step=function($name,Closure $fn)use(&$p,$save,$ok){if(!isset($p['steps'][$name])){$p['steps'][$name]=$fn();$save();}$ok(true,$name.' recorded once');return$p['steps'][$name];};$cmd=function($name,$actor,$data)use($step,$faCommand){return$step($name,fn()=>$faCommand($actor,$data)[0]);};$gl=function($name,$role,$action,$data)use($step,$call){return$step($name,fn()=>$call($role,$action==='effect-prepare'?'prepare':str_replace('-','_',$action),fn($e)=>$e->command($action,$data,(string)Str::uuid())));};
$old=App\Modules\Accounting\FixedAssets::class;$plan=['operation'=>'correct_plan','asset_key'=>$p['asset'],'life_months'=>10,'status'=>'active','evidence'=>'Correcção técnica artificial do plano12 para10 explicitamente autorizada na regressão.'];
if(!isset($p['steps']['plan_correct_prepare'])){$bad=$plan;$bad['operation']='amend';$reject(fn()=>$faCall(15,'preview',$faInput('plan',$p['plan'],2,$bad)),409,'ordinary life change with posted quotas denied');}
$cmd('plan_correct_prepare',15,$faInput('plan',$p['plan'],2,$plan));$cmd('plan_correct_review',16,$faInput('plan',$p['plan'],3,['decision'=>'approved'],'review'));
$charge=['operation'=>'amend','asset_key'=>$p['asset'],'plan_key'=>$p['plan'],'month'=>'2026-10','evidence'=>'Nova quota120 calculada a partir do plano10 revisto na regressão.'];$cmd('charge_correct_prepare',15,$faInput('charge',$p['charge'],2,$charge));$cr=$cmd('charge_correct_review',16,$faInput('charge',$p['charge'],3,['decision'=>'approved'],'review'));
$group=$gl('correction_prepare','accounting_prepare','correct-prepare',['journal_id'=>$p['steps']['charge_ledger_post']['journal_id'],'journal_on'=>'2026-10-31','period_key'=>'QA-2026-INTEGRADO','reason'=>'Par técnico explícito estorno100 e substituição120.']);
$gl('correction_review','accounting_review','correct-validate',['correction_id'=>$group['correction_id'],'revision'=>$group['revision'],'decision'=>'approved','reason'=>'Revisão distinta do par técnico100 para120 artificial.']);
$input=['correction_id'=>$group['correction_id'],'revision'=>$group['revision'],'reason'=>'Aplicar par indivisível estorno100 e substituição120.'];
if(!isset($p['steps']['correction_post'])){
$before=$native->table('gl_journals')->count();$failed=false;try{$call('accounting_prepare','correct_post',function($e)use($input){$r=new ReflectionProperty($e,'checkpoint');$r->setValue($e,function($stage){if($stage==='after_correction_first')throw new RuntimeException('injected_asset_pair_failure');});return$e->command('correct-post',$input,(string)Str::uuid());});}catch(RuntimeException$e){if($e->getMessage()!=='injected_asset_pair_failure')throw$e;$failed=true;}$ok($failed&&$native->table('gl_journals')->count()===$before,'failure after first correction header rolls back pair');
}
$out=$gl('correction_post','accounting_prepare','correct-post',$input);
$balance=app($old)->balance($f['scope'],$p['asset'],$native);if(!isset($p['steps']['writeoff_post']))$ok($balance['accumulated_minor']===12000&&$balance['carrying_minor']===108000,'pair gives accumulated120 carrying1080');
$disposal=['operation'=>'establish','asset_key'=>$p['asset'],'effect_on'=>'2026-11-01','evidence'=>'Abate artificial sem contraprestação só no livro de regressão.'];$cmd('disposal_prepare',15,$faInput('disposal',$p['disposal'],0,$disposal));$dr=$cmd('disposal_review',16,$faInput('disposal',$p['disposal'],1,['decision'=>'approved'],'review'));
$dp=$gl('writeoff_prepare','accounting_prepare','effect-prepare',['effect_type'=>'assets.writeoff','source_id'=>$dr['id'],'journal_on'=>'2026-11-01','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-WRITEOFF','reason'=>'Abate artificial sem contraprestação em três linhas.']);$gl('writeoff_review','accounting_review','validate',['proposal_id'=>$dp['proposal_id'],'revision'=>$dp['revision'],'decision'=>'approved','reason'=>'Revisão distinta do abate artificial em três linhas.']);$dj=$gl('writeoff_post','accounting_prepare','post',['proposal_id'=>$dp['proposal_id'],'revision'=>$dp['revision'],'reason'=>'Aplicar abate artificial após revisão distinta.']);$lines=$native->table('gl_lines')->where('journal_id',$dj['journal_id'])->orderBy('line_no')->get();$ok($lines->count()===3&&array_sum($lines->pluck('debit_minor')->all())===120000&&array_sum($lines->pluck('credit_minor')->all())===120000,'three line writeoff120 accumulated and1080 loss balanced1200');$ok(app($old)->balance($f['scope'],$p['asset'],$native)['carrying_minor']===0,'written off carrying zero');
$reject(fn()=>$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>'assets.depreciation','source_id'=>$cr['id'],'journal_on'=>'2026-10-31','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-DEPRECIATION','reason'=>'Tentativa indevida de duplicar quota já aplicada.'],(string)Str::uuid())),409,'duplicate depreciation after writeoff denied');
file_put_contents($work.'/assets-correction.json',json_encode(['passed'=>true,'checks'=>$checks,'regression_only'=>true,'pair_atomic'=>true,'writeoff_three_lines'=>true],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-correction.json']).PHP_EOL;
+2
View File
@@ -0,0 +1,2 @@
<?php
$book='8085';require __DIR__.'/sgf-assets-proof-bootstrap.php';config(['sales_ledger.synthetic_enabled'=>true,'cost_ledger.synthetic_enabled'=>true,'industrial_ledger.synthetic_enabled'=>true,'cost_ledger.correction_enabled'=>true,'industrial_ledger.correction_enabled'=>true,'cash_qa.enabled'=>true,'cash_qa.binding_sha256'=>hash_file('sha256',$s['root'].'/cash-binding.json')]);try{$out=$call('read_only','view',function($e){$method=new ReflectionMethod($e,'periodSnapshot');return$method->invoke($e,'QA-2026-INTEGRADO');});}catch(Throwable $e){echo json_encode(['error'=>get_class($e),'file'=>$e->getFile(),'line'=>$e->getLine(),'trace'=>array_map(fn($t)=>array_intersect_key($t,array_flip(['file','line','class','function'])),array_slice($e->getTrace(),0,9))]);exit(1);}file_put_contents($work.'/assets-current-period-private.json',json_encode($out));$pending=$out['pending']??$out['pending_facts']??null;$keys=array_keys($out);echo json_encode(['keys'=>$keys,'pending'=>$pending,'facts'=>$out['facts']??null,'proof'=>$work.'/assets-current-period-private.json']).PHP_EOL;
+1
View File
@@ -0,0 +1 @@
import fs from'node:fs';import assert from'node:assert/strict';import{session}from'./sgf-single-http.mjs';const checks=[],ok=(v,n)=>{assert(v,n);checks.push(n);};const s=await session('sgf:finance_review','accounting');let r=await s.get('/api/service-fiscal');assert.equal(r.status,200);const c=(await r.json()).coverage,a=c.management.find(v=>v.id==='accounting');ok(c.version==='SGF-FISCAL-COVERAGE-v4'&&a.available.includes('activos QA integrados'),'coverage recognises delivered assets QA');ok(a.pending.includes('tratamento fiscal')&&!c.legal_validation&&!c.official_submission_enabled,'legal fiscal and official gates remain closed');const reader=await session('sgf:read_only','accounting'),root=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')).root+'/private/receipt-composition-cc50a276c10c',p=JSON.parse(fs.readFileSync(root+'/assets-http-progress-private.json'));r=await reader.get('/api/fixed-assets/ledger?asset_key='+p.asset);const ledger=await r.json();ok(r.status===200&&ledger.records.data.filter(p=>p.journal).length===2&&ledger.balance.carrying_minor===110000,'same preserved two asset journals visible after source version guard');const proof=root+'/assets-final-read-http-20261111.json';fs.writeFileSync(proof,JSON.stringify({passed:true,checks,business_commands:0,new_demo:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length,proof}));
+14 -2
View File
@@ -1,2 +1,14 @@
import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';
const root=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')).root+'/private/receipt-composition-cc50a276c10c',s=JSON.parse(fs.readFileSync(root+'/state.json')),checks=[];const names=['sgf-entry-menu','sgf-stock-final','sgf-stock-completion','sgf-stock-catalogue','sgf-service-documents','sgf-cash','sgf-iva','sgf-fiscal-rules','sgf-bank','sgf-indicators'];for(const name of names){const path='/'+name+'.mjs',r=await fetch('http://127.0.0.1:8085'+path),bytes=Buffer.from(await r.arrayBuffer());assert.equal(r.status,200,path);assert(/javascript|ecmascript/.test(r.headers.get('content-type')),path+' JavaScript MIME');assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'),crypto.createHash('sha256').update(fs.readFileSync(s.app+'/public'+path)).digest('hex'),path+' published bytes');checks.push(path+' actual JavaScript MIME and exact bytes');}const queried=await fetch('http://127.0.0.1:8085/sgf-stock-final.mjs?check=1');assert(/javascript/.test(queried.headers.get('content-type')));checks.push('query string preserves module delivery');const missing=await fetch('http://127.0.0.1:8085/unknown-sgf-module.mjs');assert.equal(missing.status,404);assert(!/html/.test(missing.headers.get('content-type')));checks.push('missing module404 not application HTML');const old=await fetch('http://127.0.0.1:8085/stock-operations-workspace.mjs');assert.equal(old.status,200);assert(/javascript/.test(old.headers.get('content-type')));checks.push('existing physical operations script still served');const file=root+'/assets-http-20261110.json';fs.writeFileSync(file,JSON.stringify({passed:true,checks,business_commands:0,database_writes:0,browser_visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length,proof:file}));
import fs from'node:fs';import assert from'node:assert/strict';import crypto from'node:crypto';import{session}from'./sgf-single-http.mjs';
const root=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')).root+'/private/receipt-composition-cc50a276c10c',file=root+'/assets-http-progress-private.json',saved=fs.existsSync(file)?JSON.parse(fs.readFileSync(file)):{asset:crypto.randomUUID(),plan:crypto.randomUUID(),charge:crypto.randomUUID(),steps:{}},save=()=>fs.writeFileSync(file,JSON.stringify(saved));save();const checks=[],ok=(v,n)=>{assert(v,n);checks.push(n);},prep=await session('sgf:accounting_prepare','accounting'),review=await session('sgf:accounting_review','accounting'),read=await session('sgf:read_only','accounting');
ok(prep.identity.context.fixed_assets_enabled===true,'same installation asset feature enabled');const get=async(path,s=read)=>{const r=await s.get(path);assert.equal(r.status,200,path);return r.json();};
async function command(name,s,body,domain='admin'){const step=saved.steps[name]??={key:crypto.randomUUID(),body};save();assert.equal(JSON.stringify(step.body),JSON.stringify(body),'stable saved command');if(step.result)return step.result;const receiptPath=domain==='ledger'?'/api/general-ledger/receipts/':'/api/fixed-assets/receipts/';let r=await s.get(receiptPath+step.key);if(r.status===200){step.result=(await r.json()).result;save();return step.result;}assert.equal(r.status,404);const base='/api/fixed-assets'+(domain==='ledger'?'/ledger':'');const cut=await s.command(base+'/preview',body,step.key);assert.equal(cut.status,200,cut.value.message);step.cut=cut.value;save();const out=await s.command(base+'/confirm',{preview:cut.value.preview},step.key);assert.equal(out.status,200,out.value.message);step.result=out.value;save();ok(true,name+' once HTTP preview confirm');return out.value;}
const input=(kind,key,version,data,action='prepare')=>({kind,record_key:key,version,action,data,reason:'Conferência individual do ciclo de activos QA aprovado, sem operação real.'});
const asset={operation:'establish',code:'QA-ACTIVO-01',description:'Equipamento fictício de apresentação do ciclo de activos','origin_reference':'QA-ABERTURA-ACTIVO-01',cost:'1200.00',residual:'0.00',available_on:'2026-10-01',location:'QA-SALA-A',custodian:'QA-RESPONSAVEL-ACTIVO-01',evidence:'Abertura exclusivamente de ensaio aprovada; sem aquisição, dívida ou Stock.'};
await command('assetPrepare',prep,input('asset',saved.asset,0,asset));const ar=await command('assetReview',review,input('asset',saved.asset,1,{decision:'approved'},'review'));
const gl=async(name,kind,source,on,rule)=>{const p=await command(name+'Prepare',prep,{action:'effect-prepare',data:{effect_type:kind,source_id:source,journal_on:on,period_key:'QA-2026-INTEGRADO',rule_key:rule,reason:'Preparação individual QA da origem revista de activos.'}},'ledger');await command(name+'Review',review,{action:'validate',data:{proposal_id:p.proposal_id,revision:p.revision,decision:'approved',reason:'Revisão independente QA da origem de activos e contas.'}},'ledger');return command(name+'Post',prep,{action:'post',data:{proposal_id:p.proposal_id,revision:p.revision,reason:'Aplicação individual QA do impacto de activos conferido.'}},'ledger');};
await gl('admission','assets.admitted',ar.id,'2026-10-01','QA-ASSET-ADMISSION');await command('planPrepare',prep,input('plan',saved.plan,0,{operation:'establish',asset_key:saved.asset,life_months:12,status:'active',evidence:'Vida artificial12 meses e residual0 explícitos; sem tabela fiscal legal.'}));await command('planReview',review,input('plan',saved.plan,1,{decision:'approved'},'review'));await command('chargePrepare',prep,input('charge',saved.charge,0,{operation:'establish',asset_key:saved.asset,plan_key:saved.plan,month:'2026-10',evidence:'Quota Outubro100 calculada pelo plano revisto, decisão individual sem automatismo.'}));const cr=await command('chargeReview',review,input('charge',saved.charge,1,{decision:'approved'},'review'));await gl('depreciation','assets.depreciation',cr.id,'2026-10-31','QA-ASSET-DEPRECIATION');
const state=await get('/api/fixed-assets/ledger?asset_key='+saved.asset);ok(state.balance.cost_minor===120000&&state.balance.accumulated_minor===10000&&state.balance.carrying_minor===110000&&!state.balance.disposed,'actual8085 cost1200 accumulated100 carrying1100');ok(state.records.data.filter(p=>p.journal).length===2,'two actual journals for explicit new asset');const list=await get('/api/fixed-assets?kind=asset&search=QA-ACTIVO-01&per_page=25');ok(list.records.data.length===1&&list.records.data[0].effective.version===2,'single new reviewed asset visible');const history=await get('/api/fixed-assets?kind=asset&record_key='+saved.asset);ok(history.records.data.length===2,'preparation and review history preserved');
if(!saved.replayed){const st=saved.steps.depreciationPost;const out=await prep.command('/api/fixed-assets/ledger/confirm',{preview:st.cut.preview},st.key);assert.equal(out.status,200,out.value.message);assert(out.value.replayed);saved.replayed=true;save();}ok(saved.replayed,'same post confirmation recovered without duplication');
const denied=await read.command('/api/fixed-assets/preview',input('asset',crypto.randomUUID(),0,asset));ok(denied.status===403,'read only cannot prepare');const finance=await session('sgf:finance_prepare','finance').catch(()=>null);if(finance){const r=await finance.get('/api/fixed-assets?kind=asset');ok([403,409].includes(r.status),'finance profile cannot enter assets');}let r=await read.get('/api/fixed-assets?kind=asset&tenant_id=2');ok(r.status===422,'tenant override denied');r=await review.get('/api/fixed-assets/receipts/'+saved.steps.assetPrepare.key);ok(r.status===404,'other actor receipt denied');r=await read.get('/api/fixed-assets?kind=asset&per_page=1000');ok(r.status===422,'unbounded page denied');
const manifest=JSON.parse(fs.readFileSync(root+'/app/public/build/manifest.json'));for(const e of Object.values(manifest).filter(e=>e.isEntry||/FixedAssetsWorkspace/.test(e.file))){r=await fetch('http://127.0.0.1:8085/build/'+e.file);const b=Buffer.from(await r.arrayBuffer());ok(r.status===200&&/javascript/.test(r.headers.get('content-type'))&&crypto.createHash('sha256').update(b).digest('hex')===crypto.createHash('sha256').update(fs.readFileSync(root+'/app/public/build/'+e.file)).digest('hex'),'served MIME and SHA '+e.file);}
const proof=root+'/assets-http-20261111.json';fs.writeFileSync(proof,JSON.stringify({passed:true,checks,administrative_commands:6,journal_commands:6,new_journals:2,new_demo:false,browser_visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length,proof}));
+2
View File
@@ -0,0 +1,2 @@
<?php
require __DIR__.'/sgf-single-bootstrap.php';echo $native->selectOne("SELECT pg_get_functiondef('gl_cycle_pair_integrity()'::regprocedure) d")->d;echo PHP_EOL;echo json_encode(array_keys(json_decode(file_get_contents($s['root'].'/assets-regression-progress-private.json'),true)['steps']));
+12
View File
@@ -0,0 +1,12 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$host->beginTransaction();$native->beginTransaction();try{
$key=(string)Str::uuid();$a=['operation'=>'establish','code'=>'QA-WRAPPER-'.strtoupper(substr($key,0,8)),'description'=>'Equipamento de ensaio HTTP revertido','origin_reference'=>'QA-WRAPPER-OPEN-'.strtoupper(substr($key,0,8)),'cost'=>'1200.00','residual'=>'0.00','available_on'=>'2026-10-01','location'=>'QA-SALA-A','custodian'=>'QA-CUSTODIA-A','evidence'=>'Fixture do controlador de conferência contabilística.'];$faCommand(15,$faInput('asset',$key,0,$a));$ar=$faCommand(16,$faInput('asset',$key,1,['decision'=>'approved'],'review'))[0];
$jc=app(App\Modules\Accounting\FixedAssetJournalController::class);$select=function($actor)use($faCall){$faCall($actor,'index',['kind'=>'asset']);app(App\Shared\Context\CompanyContext::class)->selectFor($actor,1,2,2);};$invoke=function($actor,$method,$input,$key=null)use($select,$jc){$select($actor);request()->replace($input);request()->headers->set('Idempotency-Key',$key??(string)Str::uuid());$out=$jc->$method(request());return['status'=>$out->getStatusCode(),'value'=>$out->getData(true)];};
$d=['action'=>'effect-prepare','data'=>['effect_type'=>'assets.admitted','source_id'=>$ar['id'],'journal_on'=>'2026-10-01','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-ADMISSION','reason'=>'Prévia individual do escritor contabilístico original.']];$count=$native->table('gl_proposals')->count();$key=(string)Str::uuid();$cut=$invoke(15,'preview',$d,$key)['value'];$ok($native->table('gl_proposals')->count()===$count&&count($cut['impact']['snapshots'][0]['lines'])===2,'ledger preview rollback and two typed lines');$out=$invoke(15,'confirm',['preview'=>$cut['preview']])['value'];$again=$invoke(15,'confirm',['preview'=>$cut['preview']])['value'];$ok($again['replayed']&&$again['proposal_id']===$out['proposal_id'],'ledger confirmation receipt no duplicate');$reject(fn()=>$invoke(17,'preview',$d),403,'ledger reader cannot prepare');
$rv=['action'=>'validate','data'=>['proposal_id'=>$out['proposal_id'],'revision'=>$out['revision'],'decision'=>'approved','reason'=>'Revisão independente da captura do activo.']];$cut=$invoke(16,'preview',$rv)['value'];$ok($native->table('gl_reviews')->where('proposal_id',$out['proposal_id'])->count()===0,'review preview also rolls back');$invoke(16,'confirm',['preview'=>$cut['preview']]);$post=['action'=>'post','data'=>['proposal_id'=>$out['proposal_id'],'revision'=>$out['revision'],'reason'=>'Aplicar captura de activo após conferência independente.']];$cut=$invoke(15,'preview',$post)['value'];$ok($cut['impact']['journal_count']===1&&$native->table('gl_journals')->where('proposal_id',$out['proposal_id'])->count()===0,'post preview shows impact without journal');$j=$invoke(15,'confirm',['preview'=>$cut['preview']])['value'];$ok($native->table('gl_journals')->where('id',$j['journal_id'])->value('debit_minor')===120000,'wrapper posts through original journal');
$bad=$a;$bad['operation']='amend';$bad['cost']='1300.00';$reject(fn()=>$faCall(15,'preview',$faInput('asset',$aKey=$ar['record_key'],2,$bad)),409,'posted cost cannot silently change');$legacy=$native->table('gl_proposals')->whereRaw("snapshot->'source'->>'effect_type' NOT LIKE 'assets.%'")->value('id');if($legacy)$reject(fn()=>$invoke(16,'preview',['action'=>'validate','data'=>['proposal_id'=>$legacy,'revision'=>str_repeat('a',64),'decision'=>'approved','reason'=>'Tentativa de acesso a outra família pelo adaptador.']]),403,'asset wrapper denies legacy proposal');
$badType=$d;$badType['data']['effect_type']='sales.invoice';$reject(fn()=>$invoke(15,'preview',$badType),422,'asset wrapper cannot prepare another family');
}finally{$native->rollBack();$host->rollBack();}
// New function retains every previous branch and validates pre-existing balanced journals read-only.
$ids=$native->table('gl_journals')->whereRaw("snapshot->'source'->>'effect_type' NOT LIKE 'assets.%'")->orderBy('id')->pluck('id');foreach($ids as$id)$native->select('SELECT gl_assert_balanced(?)',[$id]);$ok(true,$ids->count()?'existing journal balance constraints remain satisfied':'regression has no legacy journals; verify27 on8085 read-only separately');
file_put_contents($work.'/assets-journal-boundaries.json',json_encode(['passed'=>true,'checks'=>$checks,'legacy_balanced_journals'=>$ids->count(),'fixtures_rolled_back'=>true,'same_writer'=>true],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-journal-boundaries.json']).PHP_EOL;
@@ -0,0 +1,2 @@
<?php
$book='8085';require __DIR__.'/sgf-single-bootstrap.php';$periods=$native->table('gl_periods')->where(['tenant_id'=>1,'company_id'=>2,'unit_id'=>2])->orderBy('id')->get(['period_key','version','starts_on','ends_on','status'])->all();file_put_contents($work.'/assets-ledger-context-private.json',json_encode(['periods'=>$periods,'journals'=>$native->table('gl_journals')->count(),'accounts'=>$native->table('gl_accounts')->where(['tenant_id'=>1,'company_id'=>2,'unit_id'=>2])->count()],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'periods'=>$periods,'journals'=>$native->table('gl_journals')->count(),'accounts'=>$native->table('gl_accounts')->where(['tenant_id'=>1,'company_id'=>2,'unit_id'=>2])->count(),'proof'=>$work.'/assets-ledger-context-private.json']).PHP_EOL;
+2
View File
@@ -0,0 +1,2 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$out=$call('read_only','view',function($e,$actor,$binding,$origin,$scope,$stock,$hostRead)use($native){return$hostRead(fn()=>app(App\Modules\Accounting\FixedAssetLedgerSource::class)->period(App\Models\User::findOrFail(17),['period_key'=>'QA-2026-INTEGRADO','starts_on'=>'2026-01-01','ends_on'=>'2026-12-31'],$native,$scope));});$ok($out['facts']===4&&$out['pending']===1,'period recognises written off corrected cycle and pending concurrent admission');$ok(strlen($out['hash'])===64,'period dependencies fingerprinted');file_put_contents($work.'/assets-period.json',json_encode(['passed'=>true,'checks'=>$checks,'asset_facts'=>$out['facts'],'pending'=>$out['pending'],'reason'=>'One explicitly approved concurrency support asset has no admission journal. Main corrected and written-off cycle resolved.','new_commands'=>0],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-period.json']).PHP_EOL;
+8
View File
@@ -0,0 +1,8 @@
<?php
$book='8085';require __DIR__.'/sgf-single-bootstrap.php';$backup=json_decode(file_get_contents($s['root'].'/assets-backup-pointer.json'),true)['root'];$before=json_decode(file_get_contents($backup.'/preservation-private.json'),true);$expected=['host.permission_grants'=>5,'host.identity_audit'=>11,'native.gl_accounts'=>5,'native.gl_rules'=>6,'native.gl_proposals'=>2,'native.gl_reviews'=>2,'native.gl_journals'=>2,'native.gl_lines'=>4,'native.gl_commands'=>6,'native.gl_events'=>6,'native.fs_versions'=>4,'native.fs_commands'=>4,'native.fs_results'=>1,'native.fs_result_lines'=>23,'native.audit_logs'=>4];$checks=[];$changes=[];$canonical=fn($r)=>Tempo\Finance\Payables\Payables::canonical((array)$r);
foreach(['host','native']as$name){$db=$name==='host'?$host:$native;foreach($before[$name]as$table=>$json){$key=$name.'.'.$table;$old=json_decode($json,true);$current=$db->table($table)->get()->map(fn($r)=>(array)$r)->all();$seen=[];foreach($current as$r)$seen[$canonical($r)]=true;foreach($old as$r)if(!isset($seen[$canonical($r)]))throw new LogicException('Original row changed: '.$key);$delta=count($current)-count($old);if($delta!==($expected[$key]??0))throw new LogicException('Unexpected append '.$key.' '.$delta.' expected '.($expected[$key]??0));$checks[]=$key;if($delta)$changes[$key]=$delta;}}
foreach($before['files']as$file=>$sha){if(hash_file('sha256',$s['root'].'/'.$file)!==$sha)throw new LogicException('Binding changed '.$file);$checks[]=$file;}
$scope=['tenant_id'=>1,'company_id'=>2,'unit_id'=>2];$grants=$host->table('permission_grants')->where($scope)->where('permission','like','fixed_assets.%')->orderBy('id')->get(['user_id','permission'])->map(fn($r)=>(array)$r)->all();$p=json_decode(file_get_contents($repo.'/docs/integration/sgf-fixed-assets-access-proposal.json'),true);if($grants!==$p['grants_per_book'])throw new LogicException('Grant perimeter differs');if($host->table('fa_versions')->count()!==6||$host->table('fa_commands')->count()!==6)throw new LogicException('Expected single asset plan quota decisions');if($native->table('gl_journals')->count()!==29||$native->table('gl_accounts')->count()!==24)throw new LogicException('Journal/chart count mismatch');if($native->table('gl_periods')->where($scope)->orderByDesc('version')->value('status')!=='open')throw new LogicException('Period must remain open');
$oldSnapshot=file_get_contents($s['root'].'/statements-frozen-export-private.csv');$oldFs=json_decode(file_get_contents($beforeFile=$s['root'].'/statements-http-progress.json'),true)['resultReview']['result']['id'];$oldData=json_decode($native->table('fs_versions')->where('id',$oldFs)->value('data'),true);if(hash('sha256',$oldSnapshot)!==$oldData['csv_sha256'])throw new LogicException('Old snapshot SHA mismatch');
$public=$host->selectOne("SELECT EXISTS(SELECT 1 FROM information_schema.table_privileges WHERE grantee='PUBLIC' AND table_name IN('fa_meta','fa_versions','fa_commands')) p")->p;if($public)throw new LogicException('Public administrative access prohibited');$checks[]='new_rights_exact';$checks[]='6administrative_versions_receipts';$checks[]='29journals24accounts';$checks[]='period_remains_open';$checks[]='old_statement_sha_preserved';$checks[]='no_public_assets_tables';
file_put_contents($work.'/assets-preservation.json',json_encode(['passed'=>true,'checks'=>$checks,'checked_original_sets'=>count($before['host'])+count($before['native']),'original_rows_identical'=>true,'approved_appends'=>$changes,'bindings_unchanged'=>3,'new_demo'=>false,'cache_cleared'=>false,'protected_records_unchanged'=>true],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'original_sets'=>count($before['host'])+count($before['native']),'proof'=>$work.'/assets-preservation.json']).PHP_EOL;
+9
View File
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/sgf-ledger-proof-bootstrap.php';
config(['fixed_assets.enabled'=>true,'fixed_assets.schema_sha256'=>hash_file('sha256',$repo.'/database/sql/fixed_assets.sql'),'fixed_assets.native_schema_sha256'=>hash_file('sha256',$repo.'/database/sql/fixed_assets_native.sql')]);
$faCall=function(int $actor,string $method,array $input=[],?string $key=null,string $generation='assets-proof',string $module='accounting',?array $scope=null)use($app,$f){
Illuminate\Support\Facades\Auth::forgetGuards();Illuminate\Support\Facades\Auth::shouldUse('web');$u=App\Models\User::findOrFail($actor);Illuminate\Support\Facades\Auth::guard('web')->setUser($u);$r=Illuminate\Http\Request::create('/api/fixed-assets',$method==='index'?'GET':'POST',$input);$r->headers->set('Idempotency-Key',$key??(string)Illuminate\Support\Str::uuid());$r->attributes->set('sgf_module',$module);$r->setUserResolver(fn()=>$u);$session=app('session')->driver();$session->start();$session->put('company_context',($scope??$f['scope'])+['module_id'=>$module,'token'=>$generation.'-'.$actor]);$r->setLaravelSession($session);$app->instance('request',$r);$ctx=app(App\Shared\Context\CompanyContext::class);$v=$scope??$f['scope'];$ctx->selectFor($actor,$v['tenant_id'],$v['company_id'],$v['unit_id']);try{$c=app(App\Modules\Accounting\FixedAssetsController::class);$out=$method==='receipt'?$c->receipt($r,app(App\Modules\Accounting\FixedAssets::class),$key):$c->$method($r,app(App\Modules\Accounting\FixedAssets::class));return['status'=>$out->getStatusCode(),'value'=>$out->getData(true)];}finally{$ctx->clear();}
};
$faInput=fn($kind,$key,$version,$data,$action='prepare')=>['kind'=>$kind,'record_key'=>$key,'version'=>$version,'action'=>$action,'data'=>$data,'reason'=>'Conferência individual do ciclo de activos artificiais autorizado.'];
$faCommand=function($actor,$input,$key=null)use($faCall){$key??=(string)Illuminate\Support\Str::uuid();$cut=$faCall($actor,'preview',$input,$key);if($cut['status']!==200)throw new LogicException(json_encode($cut));$out=$faCall($actor,'confirm',['preview'=>$cut['value']['preview']]);if($out['status']!==200)throw new LogicException(json_encode($out));return[$out['value'],$cut['value'],$key];};
$reject=function($fn,$status,$label)use($ok){$actual=null;try{$v=$fn();$actual=$v['status']??null;}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){$actual=$e->getStatusCode();}catch(Illuminate\Validation\ValidationException){$actual=422;}catch(DomainException){$actual=409;}$ok($actual===$status,$label);};
+19
View File
@@ -0,0 +1,19 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';
$progress=$s['root'].'/assets-regression-progress-private.json';$p=is_file($progress)?json_decode(file_get_contents($progress),true):['asset'=>(string)Str::uuid(),'plan'=>(string)Str::uuid(),'charge'=>(string)Str::uuid(),'disposal'=>(string)Str::uuid(),'steps'=>[]];$save=function()use(&$p,$progress){file_put_contents($progress,json_encode($p));};$save();
$step=function($name,Closure $fn)use(&$p,$save,$ok){if(!isset($p['steps'][$name])){$p['steps'][$name]=$fn();$save();}$ok(true,$name.' recorded once');return$p['steps'][$name];};
$cmd=function($name,$actor,$data)use($step,$faCommand){return $step($name,fn()=>$faCommand($actor,$data)[0]);};
$a=['operation'=>'establish','code'=>'QA-ACTIVO-REG-01','description'=>'Equipamento artificial de regressão integrado','origin_reference'=>'QA-ABERTURA-ACTIVO-REG-01','cost'=>'1200.00','residual'=>'0.00','available_on'=>'2026-10-01','location'=>'QA-SALA-A','custodian'=>'QA-RESPONSAVEL-ACTIVO-01','evidence'=>'Origem exclusivamente artificial do ciclo aprovado.'];
$reject(fn()=>$faCall(17,'preview',$faInput('asset',$p['asset'],0,$a)),403,'reader cannot prepare');$reject(fn()=>$faCall(15,'preview',$faInput('asset',$p['asset'],0,$a),null,'assets-proof','finance'),403,'wrong module denied');$reject(fn()=>$faCall(13,'index',['kind'=>'asset']),403,'finance actor denied');
$ap=$cmd('asset_prepare',15,$faInput('asset',$p['asset'],0,$a));
if(!isset($p['steps']['asset_review']))$reject(fn()=>$faCall(15,'preview',$faInput('asset',$p['asset'],1,['decision'=>'approved'],'review')),403,'own review denied');
$ar=$cmd('asset_review',16,$faInput('asset',$p['asset'],1,['decision'=>'approved'],'review'));
$gl=function($name,$role,$action,$data)use($step,$call){return$step($name,fn()=>$call($role,$action==='effect-prepare'?'prepare':str_replace('-','_',$action),fn($e)=>$e->command($action,$data,(string)Str::uuid())));};
$ad=$gl('admission_prepare','accounting_prepare','effect-prepare',['effect_type'=>'assets.admitted','source_id'=>$ar['id'],'journal_on'=>'2026-10-01','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-ADMISSION','reason'=>'Admissão individual artificial revista na regressão.']);
$gl('admission_review','accounting_review','validate',['proposal_id'=>$ad['proposal_id'],'revision'=>$ad['revision'],'decision'=>'approved','reason'=>'Revisão distinta da admissão artificial na regressão.']);
$aj=$gl('admission_post','accounting_prepare','post',['proposal_id'=>$ad['proposal_id'],'revision'=>$ad['revision'],'reason'=>'Aplicação individual da admissão artificial revista.']);
$plan=['operation'=>'establish','asset_key'=>$p['asset'],'life_months'=>12,'status'=>'active','evidence'=>'Plano linear artificial12 meses aprovado explicitamente.'];$cmd('plan_prepare',15,$faInput('plan',$p['plan'],0,$plan));$cmd('plan_review',16,$faInput('plan',$p['plan'],1,['decision'=>'approved'],'review'));
$charge=['operation'=>'establish','asset_key'=>$p['asset'],'plan_key'=>$p['plan'],'month'=>'2026-10','evidence'=>'Quota artificial Outubro após admissão, sem automatismo.'];$cmd('charge_prepare',15,$faInput('charge',$p['charge'],0,$charge));$cr=$cmd('charge_review',16,$faInput('charge',$p['charge'],1,['decision'=>'approved'],'review'));
$cp=$gl('charge_ledger_prepare','accounting_prepare','effect-prepare',['effect_type'=>'assets.depreciation','source_id'=>$cr['id'],'journal_on'=>'2026-10-31','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-DEPRECIATION','reason'=>'Quota Outubro100 artificial revista individualmente.']);$gl('charge_ledger_review','accounting_review','validate',['proposal_id'=>$cp['proposal_id'],'revision'=>$cp['revision'],'decision'=>'approved','reason'=>'Revisão distinta da quota Outubro100 artificial.']);$cj=$gl('charge_ledger_post','accounting_prepare','post',['proposal_id'=>$cp['proposal_id'],'revision'=>$cp['revision'],'reason'=>'Aplicação da quota100 após revisão distinta.']);
$balance=app(App\Modules\Accounting\FixedAssets::class)->balance($f['scope'],$p['asset'],$native);if(!isset($p['steps']['correction_post']))$ok($balance['cost_minor']===120000&&$balance['accumulated_minor']===10000&&$balance['carrying_minor']===110000,'cost1200 accumulated100 carrying1100');
file_put_contents($work.'/assets-functional.json',json_encode(['passed'=>true,'checks'=>$checks,'steps'=>array_keys($p['steps']),'new_demo'=>false,'old_suites_reused'=>true],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-functional.json']).PHP_EOL;
+9
View File
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$host->beginTransaction();$native->beginTransaction();try{
$keys=[];foreach(['asset','plan','charge','disposal']as$k)$keys[$k]=(string)Str::uuid();$a=['operation'=>'establish','code'=>'QA-WRITEOFF-ROLLBACK','description'=>'Activo artificial para abate100 e1100 revertido','origin_reference'=>'QA-WRITEOFF-OPEN-ROLLBACK','cost'=>'1200.00','residual'=>'0.00','available_on'=>'2026-10-01','location'=>'QA-SALA-A','custodian'=>'QA-CUSTODIA-A','evidence'=>'Abate100 acumulado e1100 perda, fixture revertida.'];$admin=function($kind,$input)use($faCommand,$faInput,$keys){$faCommand(15,$faInput($kind,$keys[$kind],0,$input));return$faCommand(16,$faInput($kind,$keys[$kind],1,['decision'=>'approved'],'review'))[0];};$gl=function($kind,$id,$on,$rule)use($call){$p=$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>$kind,'source_id'=>$id,'journal_on'=>$on,'period_key'=>'QA-2026-INTEGRADO','rule_key'=>$rule,'reason'=>'Conferência de activo no recorte QA revertido.'],(string)Str::uuid()));$call('accounting_review','validate',fn($e)=>$e->command('validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Revisão distinta do activo no recorte QA revertido.'],(string)Str::uuid()));return$call('accounting_prepare','post',fn($e)=>$e->command('post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Aplicação do activo no recorte QA revertido.'],(string)Str::uuid()));};$ar=$admin('asset',$a);$gl('assets.admitted',$ar['id'],'2026-10-01','QA-ASSET-ADMISSION');$admin('plan',['operation'=>'establish','asset_key'=>$keys['asset'],'life_months'=>12,'status'=>'active','evidence'=>'Plano linear artificial de12 meses para abate.']);$cr=$admin('charge',['operation'=>'establish','asset_key'=>$keys['asset'],'plan_key'=>$keys['plan'],'month'=>'2026-10','evidence'=>'Quota100 artificial Outubro para abate.']);$gl('assets.depreciation',$cr['id'],'2026-10-31','QA-ASSET-DEPRECIATION');
$dr=$admin('disposal',['operation'=>'establish','asset_key'=>$keys['asset'],'effect_on'=>'2026-11-01','evidence'=>'Abate sintético que deve ser bloqueado se as quotas mudarem.']);$prepareWriteoff=fn()=>$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>'assets.writeoff','source_id'=>$dr['id'],'journal_on'=>'2026-11-01','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-WRITEOFF','reason'=>'Conferir abate contra todas as quotas actuais.'],(string)Str::uuid()));$wp=$prepareWriteoff();$ok(isset($wp['proposal_id']),'consistent quota permits writeoff conference');
$plan=['operation'=>'correct_plan','asset_key'=>$keys['asset'],'life_months'=>10,'status'=>'active','evidence'=>'Plano de correcção explicitamente ainda sem nova quota aplicada.'];$faCommand(15,$faInput('plan',$keys['plan'],2,$plan));$reject($prepareWriteoff,409,'pending plan decision blocks writeoff');$faCommand(16,$faInput('plan',$keys['plan'],3,['decision'=>'approved'],'review'));$reject($prepareWriteoff,409,'unresolved changed quota blocks writeoff');
$plan['life_months']=12;$faCommand(15,$faInput('plan',$keys['plan'],4,$plan));$faCommand(16,$faInput('plan',$keys['plan'],5,['decision'=>'approved'],'review'));
$dec=(string)Str::uuid();$d=['operation'=>'establish','asset_key'=>$keys['asset'],'plan_key'=>$keys['plan'],'month'=>'2026-12','evidence'=>'Competência Dezembro sem Novembro aplicado.'];$faCommand(15,$faInput('charge',$dec,0,$d));$ar=$faCommand(16,$faInput('charge',$dec,1,['decision'=>'approved'],'review'))[0];$reject(fn()=>$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>'assets.depreciation','source_id'=>$ar['id'],'journal_on'=>'2026-12-31','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-DEPRECIATION','reason'=>'Não saltar competências nem presumir o acumulado.'],(string)Str::uuid())),409,'missing preceding quota blocks excessive out of order depreciation');
$nov=(string)Str::uuid();$d['month']='2026-11';$faCommand(15,$faInput('charge',$nov,0,$d));$nr=$faCommand(16,$faInput('charge',$nov,1,['decision'=>'approved'],'review'))[0];$gl('assets.depreciation',$nr['id'],'2026-11-30','QA-ASSET-DEPRECIATION');$balance=app(App\Modules\Accounting\FixedAssets::class)->balance($f['scope'],$keys['asset'],$native);$ok($balance['accumulated_minor']===20000,'two coherent sequential quotas total200');$plan['life_months']=1;$reject(fn()=>$faCall(15,'preview',$faInput('plan',$keys['plan'],6,$plan)),409,'multiple posted quotas block single quota correction shortcut');
}finally{$native->rollBack();$host->rollBack();}file_put_contents($work.'/assets-sequence-guards.json',json_encode(['passed'=>true,'checks'=>$checks,'all_fixtures_rolled_back'=>true],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-sequence-guards.json']).PHP_EOL;
+17
View File
@@ -0,0 +1,17 @@
<?php
$book=$argv[1]??'regression';require __DIR__.'/sgf-single-bootstrap.php';
$p=json_decode(file_get_contents($repo.'/docs/integration/sgf-fixed-assets-access-proposal.json'),true);$scope=$p['scope'];if(($p['approved']??false)!==true||$p['authorization']['decision']!=='Autorizar o ciclo integrado de activos QA'||$scope!==['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]||$p['grants_per_book']!==array_merge(array_map(fn($u)=>['user_id'=>$u,'permission'=>'fixed_assets.view'],[15,16,17]),[['user_id'=>15,'permission'=>'fixed_assets.prepare'],['user_id'=>16,'permission'=>'fixed_assets.review']]))throw new LogicException('Exact approval required');if($book==='8085'&&!is_file($s['root'].'/assets-backup-pointer.json'))throw new LogicException('Backup first');
$sql=file_get_contents($repo.'/database/sql/fixed_assets.sql');$sha=hash('sha256',$sql);$grants=0;$accounts=0;$rules=0;
$host->transaction(function()use($host,$p,$sql,$sha,$tag,&$grants){if(!$host->getSchemaBuilder()->hasTable('fa_meta')){$host->unprepared($sql);$host->table('fa_meta')->insert(['policy'=>App\Modules\Accounting\FixedAssets::POLICY,'composition'=>$tag,'source_hash'=>$sha]);}else{$m=$host->table('fa_meta')->first();if(!$m||$m->source_hash!==$sha||$m->composition!==$tag)throw new LogicException('Schema differs');}
$host->statement('REVOKE ALL ON TABLE fa_meta,fa_versions,fa_commands FROM PUBLIC');$host->statement('REVOKE ALL ON SEQUENCE fa_versions_id_seq,fa_commands_id_seq FROM PUBLIC');$host->statement('REVOKE ALL ON FUNCTION fa_append_only() FROM PUBLIC');$old=$host->selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;if(!str_contains($old,'fixed_assets.view')){if(!preg_match('/^CHECK \((.*)\)$/s',$old,$m))throw new LogicException('Unknown scope check');$host->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');$host->statement('ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (('.$m[1].") OR (unit_id IS NOT NULL AND permission IN('fixed_assets.view','fixed_assets.prepare','fixed_assets.review')))");}
foreach($p['grants_per_book']as$g){$g=$p['scope']+$g;if($host->table('permission_grants')->where($g)->exists())continue;$host->table('permission_grants')->insert($g);App\Modules\Identity\Services\Audit::record('access.granted_on_creation',null,1,2,2,$g['user_id'],['permission'=>$g['permission'],'reason'=>$p['authorization'],'limits'=>'Activos artificiais QA; sem operações reais.']);$grants++;}});
$nativeSql=file_get_contents($repo.'/database/sql/fixed_assets_native.sql');$nativeSha=hash('sha256',$nativeSql);
$native->transaction(function()use($native,$host,$scope,$p,$nativeSql,$nativeSha,&$accounts,&$rules,$work){Tempo\Finance\GeneralLedger\LedgerSchema::assertSynthetic($native);if(!$native->getSchemaBuilder()->hasTable('gl_asset_books')){
$original=$native->selectOne("SELECT pg_get_functiondef('gl_assert_balanced(bigint)'::regprocedure) d")->d;file_put_contents($work.'/gl-balanced-before-private.sql',$original);$needle="WHEN j.snapshot->'source'->>'effect_type'='sales.invoice' THEN 3 ELSE 2 END";if(substr_count($original,$needle)!==1)throw new LogicException('Unrecognised balance function');$replacement="WHEN j.snapshot->'source'->>'effect_type'='sales.invoice' THEN 3 WHEN j.snapshot->'source'->>'effect_type'='assets.writeoff' THEN jsonb_array_length(j.snapshot->'lines') ELSE 2 END";$native->unprepared(str_replace($needle,$replacement,$original));$native->unprepared($nativeSql);$native->table('gl_asset_books')->insert($scope+['policy'=>'QA-ASSET-JOURNAL-v1','host_database'=>$host->getDatabaseName(),'schema_sha256'=>$nativeSha]);
}else{$m=$native->table('gl_asset_books')->where($scope)->first();if(!$m||!in_array($m->schema_sha256,[$nativeSha,'4d0a0dae2a843979ce61c17c619070f67723b235b22989c1820acfe0686a21de'],true)||$m->host_database!==$host->getDatabaseName())throw new LogicException('Native schema mismatch');if(!$native->getSchemaBuilder()->hasTable('gl_asset_extension_versions'))$native->unprepared(substr($nativeSql,strpos($nativeSql,'-- ASSET_PAIR_EXTENSION_v1')));}
$extension=$native->table('gl_asset_extension_versions')->where('version',1)->first();if($extension){if($extension->source_hash!==$nativeSha)throw new LogicException('Asset extension mismatch');}else $native->table('gl_asset_extension_versions')->insert(['version'=>1,'source_hash'=>$nativeSha]);
$cycle=$native->selectOne("SELECT pg_get_functiondef('gl_cycle_pair_integrity()'::regprocedure) d")->d;if(!str_contains($cycle,'gl_asset_pair_assert')){$needle=" IF TG_TABLE_NAME='gl_correction_results' THEN";if(!str_contains($cycle,$needle))throw new LogicException('Unknown cycle guard');file_put_contents($work.'/gl-cycle-before-private.sql',$cycle);$at=strpos($cycle,$needle);$branch=" IF TG_TABLE_NAME='gl_correction_results' THEN IF EXISTS(SELECT 1 FROM gl_journals WHERE id=NEW.reverse_journal_id AND snapshot->'source'->>'effect_type'='assets.depreciation.reversal') THEN PERFORM gl_asset_pair_assert(NEW.correction_id); RETURN NEW; END IF; END IF;\n";$native->unprepared(substr($cycle,0,$at).$branch.substr($cycle,$at));}
foreach($p['new_accounts_per_book']as$a){if($native->table('gl_accounts')->where($scope+['code'=>$a['code']])->exists())continue;$native->table('gl_accounts')->insert($scope+['code'=>$a['code'],'label'=>$a['purpose'],'is_movement'=>true,'currency'=>'MZN']);$accounts++;}
$codes=$native->table('gl_accounts')->where($scope)->pluck('id','code');$plans=[['ADMISSION','assets.admitted','CUSTO','ORIGEM-QA'],['DEPRECIATION','assets.depreciation','GASTO-DEP','DEP-ACUM'],['WRITEOFF','assets.writeoff','DEP-ACUM','CUSTO'],['WRITEOFF-NO-DEP','assets.writeoff','GASTO-ABATE','CUSTO'],['CORRECTION-REVERSE','assets.depreciation.reversal','DEP-ACUM','GASTO-DEP'],['CORRECTION-REPLACE','assets.depreciation.replacement','GASTO-DEP','DEP-ACUM']];foreach($plans as[$key,$type,$debit,$credit]){if($native->table('gl_rules')->where($scope+['rule_key'=>'QA-ASSET-'.$key])->exists())continue;$native->table('gl_rules')->insert($scope+['rule_key'=>'QA-ASSET-'.$key,'version'=>1,'policy'=>'QA-ASSET-JOURNAL-v1','effect_type'=>$type,'currency'=>'MZN','valid_from'=>'2026-01-01','valid_to'=>'2026-12-31','debit_account_id'=>$codes['QA-ACTIVO-'.$debit],'credit_account_id'=>$codes['QA-ACTIVO-'.$credit],'created_by'=>15,'reason'=>'Regra artificial do ciclo de activos expressamente autorizado.','created_at'=>now()->utc()]);$rules++;}
});
file_put_contents($work.'/assets-setup.json',json_encode(['passed'=>true,'book'=>$book,'new_grants'=>$grants,'accounts'=>$accounts,'rules'=>$rules,'new_demo'=>false,'schema_sha256'=>$sha,'native_sha256'=>$nativeSha],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'book'=>$book,'grants'=>$grants,'accounts'=>$accounts,'rules'=>$rules,'proof'=>$work.'/assets-setup.json']).PHP_EOL;
@@ -0,0 +1,2 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$host->beginTransaction();$native->beginTransaction();try{$key=(string)Str::uuid();$a=['operation'=>'establish','code'=>'QA-REVISION-'.strtoupper(substr($key,0,8)),'description'=>'Activo para versão administrativa explícita','origin_reference'=>'QA-REVISION-OPEN-'.strtoupper(substr($key,0,8)),'cost'=>'1200.00','residual'=>'0.00','available_on'=>'2026-10-01','location'=>'QA-SALA-A','custodian'=>'QA-CUSTODIA-A','evidence'=>'Fixture de versão administrativa revertida.'];$faCommand(15,$faInput('asset',$key,0,$a));$ar=$faCommand(16,$faInput('asset',$key,1,['decision'=>'approved'],'review'))[0];$data=['effect_type'=>'assets.admitted','source_id'=>$ar['id'],'journal_on'=>'2026-10-01','period_key'=>'QA-2026-INTEGRADO','rule_key'=>'QA-ASSET-ADMISSION','reason'=>'Captura anterior a uma nova preparação administrativa.'];$p=$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',$data,(string)Str::uuid()));$old=json_decode($native->table('gl_proposals')->where('id',$p['proposal_id'])->value('snapshot'),true)['source'];$a['operation']='amend';$a['location']='QA-SALA-B';$faCommand(15,$faInput('asset',$key,2,$a));$reject(fn()=>$call('accounting_review','validate',fn($e)=>$e->command('validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Não aceitar captura anterior a versão administrativa.'],(string)Str::uuid())),409,'administrative version change invalidates pending journal review');$new=$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',$data,(string)Str::uuid()));$source=json_decode($native->table('gl_proposals')->where('id',$new['proposal_id'])->value('snapshot'),true)['source'];$ok($new['revision']!==$p['revision']&&$source['administrative_revision']!==$old['administrative_revision'],'fresh capture observes new administrative version');$ok($source['recognition_revision']===$old['recognition_revision'],'custody preparation does not change financial recognition or require reversal of applied admission');}finally{$native->rollBack();$host->rollBack();}file_put_contents($work.'/assets-source-version.json',json_encode(['passed'=>true,'checks'=>$checks,'fixtures_rolled_back'=>true],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-source-version.json']).PHP_EOL;
+8
View File
@@ -0,0 +1,8 @@
import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {session} from './sgf-single-http.mjs';import {checkedStatements} from '../resources/js/internal-statements-view.js';
const root=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')).root+'/private/receipt-composition-cc50a276c10c',progress=root+'/assets-statements-http-progress-private.json',saved=fs.existsSync(progress)?JSON.parse(fs.readFileSync(progress)):{},save=()=>fs.writeFileSync(progress,JSON.stringify(saved)),checks=[],ok=(v,n)=>{assert(v,n);checks.push(n);},base='/api/general-ledger/statements';
const prep=await session('sgf:accounting_prepare','accounting'),review=await session('sgf:accounting_review','accounting'),read=await session('sgf:read_only','accounting');const get=async(q={view:'history',page:1,per_page:25},s=read)=>{const r=await s.get(base+'?'+new URLSearchParams(q));assert.equal(r.status,200);return checkedStatements(await r.json(),q);};let state=await get();ok(prep.identity.context.internal_statements_enabled===true,'selected installation feature enabled');const names=s=>s.identity.context.grants.filter(g=>g.permission.startsWith('general_ledger.statements.')).map(g=>g.permission);ok(JSON.stringify(names(prep))===JSON.stringify(['general_ledger.statements.prepare'])&&JSON.stringify(names(review))===JSON.stringify(['general_ledger.statements.review'])&&names(read).length===0,'exact approved preparation review rights only');
const accounts=await get({view:'accounts',page:1,per_page:100},prep),categories={'QA-CAIXA':'asset','QA-CLIENTES':'asset','QA-STOCK':'asset','QA-FORNECEDOR':'liability','QA-VENDAS':'income','QA-CONSUMO-STOCK':'expense','QA-CUSTO-DIRECTO':'expense','QA-CUSTO-INDUSTRIAL':'expense','QA-DESPESA-CAIXA':'expense','QA-DIFERENCA-INVENTARIO':'expense','QA-COMPENSACAO-ENSAIO':'unclassified','QA-COMPENSACAO-INDUSTRIAL':'unclassified','QA-COMPENSACAO-STOCK':'unclassified','QA-CONTRAPARTIDA':'unclassified','QA-DEVOLUCAO-FORNECEDOR':'unclassified','QA-IMPOSTO-TESTE':'unclassified','QA-LIQUIDACAO':'unclassified','QA-LIQUIDACAO-CLIENTE':'unclassified','QA-REDUCAO-CLIENTE':'unclassified','QA-ACTIVO-CUSTO':'asset','QA-ACTIVO-DEP-ACUM':'asset','QA-ACTIVO-GASTO-DEP':'expense','QA-ACTIVO-ORIGEM-QA':'unclassified','QA-ACTIVO-GASTO-ABATE':'expense'};const entries=Object.entries(categories).map(([code,category])=>{const row=accounts.records.data.find(a=>a.code===code);assert(row,'Explicit approved QA account missing '+code);return{account_id:row.id,category};});ok(entries.length===24&&entries.filter(e=>e.category==='unclassified').length===10,'explicit24 approved choices ten pending');const input=(kind,action,version,data)=>({kind,action,version,data,reason:'Decisão individual QA de demonstrações internas parciais, sem classificação legal ou novos lançamentos.'});
async function perform(name,s,body){saved[name]??={key:crypto.randomUUID()};save();const step=saved[name];if(step.result)return step.result;const old=await s.get(base+'/receipts/'+step.key);if(old.status===200){step.result=(await old.json()).result;save();return step.result;}assert.equal(old.status,404);const cut=await s.command(base+'/preview',body,step.key);assert.equal(cut.status,200,JSON.stringify(cut.value));step.cut=cut.value;save();const out=await s.command(base+'/confirm',{preview:cut.value.preview},step.key);assert.equal(out.status,201,JSON.stringify(out.value));step.result=out.value;save();return out.value;}
const m1=await perform('mappingPrepare',prep,input('mapping','prepare',2,{entries,evidence:'Conserva19 correspondências anteriores e acrescenta as cinco contas artificiais de activos explicitamente aprovadas.'}));ok(m1.version===3,'new mapping prepared version3');const m2=await perform('mappingReview',review,input('mapping','review',3,{decision:'approved'}));ok(m2.version===4,'new mapping independently reviewed version4');const r1=await perform('resultPrepare',prep,input('result','prepare',2,{mapping_id:m2.id,from_on:'2026-01-01',to_on:'2026-11-11',per_page:25,evidence:'Resultado interno parcial anual até11Nov;29 lançamentos QA incluindo admissão1200/quota100 e dez contas por classificar.'}));const r2=await perform('resultReview',review,input('result','review',3,{decision:'approved'}));ok(r1.version===3&&r2.version===4,'new result independently prepared reviewed versions3/4');const query={view:'result',version_id:r2.id,page:1,per_page:25},report=await get(query),summary=report.snapshot.summary,c=summary.currencies.MZN;ok(summary.plan.accounts===24&&summary.accounts===23&&summary.basis.journals===29&&report.current_source_matches,'24 chart accounts23 accounts with movements29 journals current source');ok(c.groups.asset.presentation_minor==='325500'&&c.groups.liability.presentation_minor==='10000','classified assets3255 supplier100');ok(c.groups.expense.presentation_minor==='230000'&&c.known_result_subtotal_minor==='28621','year-to-date expense2300 known subtotal286.21');ok(c.unclassified_accounts===10&&c.complete_result_minor===null&&c.equity_minor===null&&!c.complete_balance_sheet,'ten unclassified complete result and equity unknown');const larger=await get({...query,per_page:100});ok(JSON.stringify(larger.records.data)===JSON.stringify(report.records.data),'same frozen23 movement rows pages25/100');
const old=JSON.parse(fs.readFileSync(root+'/statements-http-progress.json')).resultReview.result;let response=await read.get(base+'/exports/'+old.id);const oldBytes=Buffer.from(await response.arrayBuffer());ok(response.status===200&&oldBytes.equals(fs.readFileSync(root+'/statements-frozen-export-private.csv')),'old reviewed export byte identical');const historical=await get({...query,version_id:old.id});ok(historical.snapshot.summary.accounts===19&&historical.snapshot.summary.basis.journals===27&&historical.current_source_matches===false,'old19/27 snapshot explicit historical');response=await read.get(base+'/exports/'+r2.id);const bytes=Buffer.from(await response.arrayBuffer());ok(response.status===200&&crypto.createHash('sha256').update(bytes).digest('hex')===report.snapshot.csv_sha256,'new partial result export and SHA');fs.writeFileSync(root+'/assets-statements-frozen-private.csv',bytes);fs.writeFileSync(root+'/assets-statements-sample-private.json',JSON.stringify(report));const proof=root+'/assets-statements-http-20261111.json';fs.writeFileSync(proof,JSON.stringify({passed:true,checks,administrative_commands:4,old_snapshot_preserved:true,new_demo:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length,proof}));
+11
View File
@@ -0,0 +1,11 @@
import fs from'node:fs';import assert from'node:assert/strict';import{pathToFileURL}from'node:url';import{transformWithOxc}from'vite';const root=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')).root+'/private/receipt-composition-cc50a276c10c',checks=[],ok=(v,n)=>{assert(v,n);checks.push(n);};let slots=[],cursor=0,effects=[],tree,calls=[],rootComponent,props;const same=(a,b)=>a&&b&&a.length===b.length&&a.every((v,i)=>v===b[i]);const R={createElement:(type,props,...children)=>({type,props:props??{},children}),Fragment:Symbol('fragment'),Suspense:Symbol('suspense'),lazy:()=>Symbol('lazy'),useRef(initial){const i=cursor++;slots[i]??={value:{current:initial}};return slots[i].value;},useState(initial){const i=cursor++;slots[i]??={value:initial};return[slots[i].value,v=>{slots[i].value=typeof v==='function'?v(slots[i].value):v;}];},useMemo(fn,deps){const i=cursor++;if(!same(slots[i]?.deps,deps))slots[i]={value:fn(),deps};return slots[i].value;},useEffect(fn,deps){const i=cursor++;if(!same(slots[i]?.deps,deps)){slots[i]?.cleanup?.();slots[i]={deps};effects.push(()=>slots[i].cleanup=fn());}}};globalThis.__hrHooks=R;const stub='const r=globalThis.__hrHooks;export default r;export const useState=r.useState,useMemo=r.useMemo,useEffect=r.useEffect,useRef=r.useRef;',reactUrl='data:text/javascript;base64,'+Buffer.from(stub).toString('base64'),compile=async(file,imports)=>{let s=fs.readFileSync('resources/js/'+file,'utf8').replace(/from\s*['"]react['"]/, 'from '+JSON.stringify(reactUrl));for(const f of imports)s=s.replace(new RegExp('from\\s*[\'"]\\./'+f.replaceAll('.','\\.')+'[\'"]'),'from '+JSON.stringify(pathToFileURL(process.cwd()+'/resources/js/'+f).href));const c=await transformWithOxc(s,file,{jsx:{runtime:'classic'}});return import('data:text/javascript;base64,'+Buffer.from(c.code).toString('base64'));};
const{default:Workspace}=await compile('FixedAssetsWorkspace.jsx',[]);const{navigationGroups}=await compile('WorkspaceNavigation.jsx',['billing-menu.js','workshop-menu.js','stock-menu.js','accounting-menu.js']);const{moduleGroups}=await compile('ModuleNavigation.jsx',[]);
const row={id:7,kind:'asset',record_key:'00000000-0000-4000-8000-000000000007',version:2,state:'approved',actor_id:16,created_at:'QA',reason:'Revisão artificial',data:{status:'active',input:{code:'QA-UI-ASSET',description:'Fixture UI',origin_reference:'QA-UI-OPEN',cost:'1200.00',residual:'0.00',available_on:'2026-10-01',location:'QA-A',custodian:'QA-A',evidence:'Evidência artificial de UI'}},effective:null};row.effective={...row};let fail=true,posted=[],previewCalls=[];
const api=async(path,method,body,unused,key)=>{calls.push({path,method,body,key});if(method==='POST'&&path.endsWith('/preview')){previewCalls.push({body,key});return{preview:'opaque',impact:{before:{version:0},after:{version:1,state:'prepared',proposal:{input:body.data}}}};}if(method==='POST'){posted.push({path,body,key});if(fail){const e=Error('Resultado incerto503');e.status=503;throw e;}return{state:'prepared',version:1};}if(path.includes('/receipts/'))return{result:{state:'prepared',version:1}};if(path.includes('/ledger?'))return{balance:{cost_minor:120000,accumulated_minor:10000,carrying_minor:110000,disposed:false},records:{data:[],last_page:1},corrections:[]};const q=new URL('http://qa'+path).searchParams;return{kind:q.get('kind'),records:{data:q.get('kind')==='asset'?[row]:[],per_page:Number(q.get('per_page')),total:26,last_page:2}};};
const render=()=>{cursor=0;tree=rootComponent(props);const jobs=effects;effects=[];jobs.forEach(f=>f());return tree;},tick=async()=>{await new Promise(r=>setTimeout(r,0));render();},descend=n=>Array.isArray(n)?n.flatMap(descend):n&&typeof n==='object'?[n,...descend(n.children)]:[],nodes=(n=tree)=>descend(n),text=n=>Array.isArray(n)?n.map(text).join(''):n&&typeof n==='object'?text(n.children):String(n??''),find=(tag,label)=>nodes().find(n=>n.type===tag&&text(n).includes(label));
rootComponent=Workspace;props={api,context:{token:'qa',unit:{id:2}},can:()=>true,busy:false,perform:async(fn)=>{try{await fn();}catch{}}};render();await tick();ok(calls.length===1&&calls[0].path.includes('kind=asset'),'bounded initial asset list only');ok(!calls.some(c=>c.path.includes('/ledger')),'no implicit first asset selection');find('button','Seguinte').props.onClick();render();await tick();ok(calls.at(-1).path.includes('page=2'),'page changes server query');find('button','QA-UI-ASSET').props.onClick();render();await tick();ok(calls.at(-1).path.includes('asset_key='+row.record_key),'ledger only for explicit asset');ok(text(tree).includes('Diário do activo seleccionado'),'asset and GL integrated detail');find('button','Novo registo').props.onClick();render();ok(!find('h2','Versão seleccionada'),'new record clears old selection');const field=(label,value)=>{find('label',label).children.find(n=>['input','textarea'].includes(n?.type)).props.onChange({target:{value}});render();};field('Código QA','QA-NEW-UI');field('Descrição','Equipamento artificial UI');field('Referência da abertura','QA-NEW-OPEN');field('Custo MZN','1200.00');field('Residual MZN','0.00');field('Entrada em uso','2026-10-01');field('Localização interna','QA-SALA-A');field('Custódia interna','QA-CUSTODIA-A');field('Evidência','Evidência artificial UI');field('Fundamento da decisão','Fundamento artificial UI');render();await nodes().find(n=>n.type==='form').props.onSubmit({preventDefault(){}});render();ok(previewCalls.length===1&&previewCalls[0].body.data.cost==='1200.00'&&previewCalls[0].body.version===0,'exact documentary amount and explicit first version');ok(Boolean(find('h2','Impacto antes da confirmação')),'impact before commit');ok(find('button','Planos mensais').props.disabled,'preview blocks navigation');await find('button','Confirmar só este impacto').props.onClick();render();ok(find('button','Planos mensais').props.disabled&&Boolean(find('button','Recuperar comprovativo')),'uncertain failure retains same conference');await find('button','Recuperar comprovativo').props.onClick();render();await tick();ok(posted.length===1&&calls.some(c=>c.path.endsWith('/receipts/'+previewCalls[0].key)),'receipt recovery does not duplicate POST');find('button','Planos mensais').props.onClick();render();await tick();const picker=nodes().find(n=>typeof n.type==='function'&&n.type.name==='ReferencePicker');ok(picker&&picker.props.value==='','dependent asset explicitly unselected');const groups=navigationGroups(r=>r.startsWith('fixed_assets.'),2,{fixedAssets:true});ok(moduleGroups(groups,'accounting').some(g=>g.items.some(i=>i.id==='fixed_assets')),'accounting dropdown with exact asset right');ok(moduleGroups(groups,'finance').length===0&&moduleGroups(groups,'human_resources').length===0,'asset right adds no other module');const hidden=navigationGroups(()=>false,2,{fixedAssets:true});ok(!hidden.flatMap(g=>g.items).some(i=>i.id==='fixed_assets'),'no asset menu without permission');
const file=root+'/assets-ui-20261111.json';fs.writeFileSync(file,JSON.stringify({passed:true,checks,api_mocked:true,browser_visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length,proof:file}));
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/sgf-assets-proof-bootstrap.php';$host->beginTransaction();$native->beginTransaction();try{
$keys=[];foreach(['asset','plan','charge','disposal']as$k)$keys[$k]=(string)Str::uuid();$a=['operation'=>'establish','code'=>'QA-WRITEOFF-ROLLBACK','description'=>'Activo artificial para abate100 e1100 revertido','origin_reference'=>'QA-WRITEOFF-OPEN-ROLLBACK','cost'=>'1200.00','residual'=>'0.00','available_on'=>'2026-10-01','location'=>'QA-SALA-A','custodian'=>'QA-CUSTODIA-A','evidence'=>'Abate100 acumulado e1100 perda, fixture revertida.'];$admin=function($kind,$input)use($faCommand,$faInput,$keys){$faCommand(15,$faInput($kind,$keys[$kind],0,$input));return$faCommand(16,$faInput($kind,$keys[$kind],1,['decision'=>'approved'],'review'))[0];};$gl=function($kind,$id,$on,$rule)use($call){$p=$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>$kind,'source_id'=>$id,'journal_on'=>$on,'period_key'=>'QA-2026-INTEGRADO','rule_key'=>$rule,'reason'=>'Conferência de activo no recorte QA revertido.'],(string)Str::uuid()));$call('accounting_review','validate',fn($e)=>$e->command('validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Revisão distinta do activo no recorte QA revertido.'],(string)Str::uuid()));return$call('accounting_prepare','post',fn($e)=>$e->command('post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Aplicação do activo no recorte QA revertido.'],(string)Str::uuid()));};$ar=$admin('asset',$a);$gl('assets.admitted',$ar['id'],'2026-10-01','QA-ASSET-ADMISSION');$admin('plan',['operation'=>'establish','asset_key'=>$keys['asset'],'life_months'=>12,'status'=>'active','evidence'=>'Plano linear artificial de12 meses para abate.']);$cr=$admin('charge',['operation'=>'establish','asset_key'=>$keys['asset'],'plan_key'=>$keys['plan'],'month'=>'2026-10','evidence'=>'Quota100 artificial Outubro para abate.']);$gl('assets.depreciation',$cr['id'],'2026-10-31','QA-ASSET-DEPRECIATION');$dr=$admin('disposal',['operation'=>'establish','asset_key'=>$keys['asset'],'effect_on'=>'2026-11-01','evidence'=>'Abate artificial sem contraprestação, saldo1100.']);$dj=$gl('assets.writeoff',$dr['id'],'2026-11-01','QA-ASSET-WRITEOFF');$lines=$native->table('gl_lines')->where('journal_id',$dj['journal_id'])->orderBy('line_no')->get();$ok($lines->count()===3&&$lines[0]->debit_minor===10000&&$lines[1]->debit_minor===110000&&$lines[2]->credit_minor===120000,'specified writeoff accumulated100 loss1100 cost1200');foreach($native->table('gl_journals')->where('snapshot->source->asset_key',$keys['asset'])->pluck('id')as$id)$native->select('SELECT gl_assert_balanced(?)',[$id]);$ok(true,'all three synthetic journals balanced');
// Isolate this asset conference from unrelated financial fixtures in the regression harness only.
// Neither production bridge nor existing financial source mappings are changed.
$concurrency=json_decode(file_get_contents($s['root'].'/assets-concurrency-1794388203631/cuts-private.json'),true);$r=$host->table('fa_versions')->where('record_key',$concurrency['record_key'])->where('state','approved')->first();$d=json_decode($r->data,true)['input'];$d['operation']='cancel';$faCommand(15,$faInput('asset',$r->record_key,2,$d));$faCommand(16,$faInput('asset',$r->record_key,3,['decision'=>'approved'],'review'));
$run=function($role,$right,$command,$input)use($call,$native){return$call($role,$right,function($e,$actor,$b,$o,$scope,$stock,$hostRead)use($native,$command,$input){(new ReflectionProperty($e,'financialScope'))->setValue($e,$scope);(new ReflectionProperty($e,'additionalPeriod'))->setValue($e,fn($p)=>$hostRead(fn()=>app(App\Modules\Accounting\FixedAssetLedgerSource::class)->period(App\Models\User::findOrFail($o['sgf_host_user']),$p,$native,$scope)));return$e->command($command,$input,(string)Str::uuid());});};
$p=$run('accounting_prepare','period_prepare','period-prepare',['period_key'=>'QA-2026-INTEGRADO','reason'=>'Conferência do recorte de activos QA no harness revertido.']);$run('accounting_review','period_validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Revisão distinta do recorte de activos no harness.']);$out=$run('accounting_prepare','period_close','period-close',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Fecho apenas do recorte sintético de activos revertido.']);$ok($out['state']==='closed','asset scope period can close after distinct review');$ok($native->table('gl_periods')->where($f['scope'])->orderByDesc('version')->value('status')==='closed','closed version observable inside rollback');
}finally{$native->rollBack();$host->rollBack();}$ok($native->table('gl_periods')->where($f['scope'])->orderByDesc('version')->value('status')==='open','regression period remains open after rollback');file_put_contents($work.'/assets-writeoff-close.json',json_encode(['passed'=>true,'checks'=>$checks,'regression_only'=>true,'all_fixtures_rolled_back'=>true,'period_harness'=>'asset-only additional period callback and qualified empty financial fixture scope; not full application closure','real_http_closure'=>false],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/assets-writeoff-close.json']).PHP_EOL;
@@ -0,0 +1 @@
import fs from'node:fs';import assert from'node:assert/strict';import{session}from'./sgf-single-http.mjs';const checks=[],ok=(v,n)=>{assert(v,n);checks.push(n);},s=await session('sgf:finance_review','accounting');let r=await s.get('/api/service-fiscal');ok(r.status===200&&r.headers.get('cache-control').includes('no-store'),'existing authorized fiscal reader private response');const d=await r.json(),c=d.coverage;ok(c.version==='SGF-FISCAL-COVERAGE-v3'&&c.taxes.length===7,'updated seven-family capability metadata');for(const id of['irps','inss']){const t=c.taxes.find(t=>t.id===id);ok(t.state==='partial'&&t.available&&t.legal_use===false,'actual internal coverage and legal gap '+id);}ok(c.management.some(m=>m.id==='human_resources'&&m.available.includes('férias')&&m.available.includes('autónomo')),'HR autonomous delivered capabilities visible');ok(c.management.find(m=>m.id==='accounting').pending.includes('Activos')&&!c.legal_validation&&!c.official_submission_enabled,'assets and legal activation remain pending');ok(!JSON.stringify(c).includes('QA-COLABORADOR-01')&&Array.isArray(d.proposals)&&d.migration_pending===false,'metadata does not reveal worker records or alter fiscal proposal list');const hr=await session('hr:hr_read','human_resources');r=await hr.get('/api/service-fiscal');ok([403,409].includes(r.status),'metadata reconciliation grants no fiscal access to HR');const root=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')).root+'/private/receipt-composition-cc50a276c10c',file=root+'/coverage-reconciliation-http-20261111.json';fs.writeFileSync(file,JSON.stringify({passed:true,checks,business_commands:0,new_grants:0,browser_visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length,proof:file}));
@@ -0,0 +1,3 @@
<?php
$book='8085';require __DIR__.'/sgf-single-bootstrap.php';$baseline=$s['root'].'/single-integration/20261111-103940-12c6c0e7';$before=json_decode(file_get_contents($baseline.'/preservation-private.json'),true);$installation=json_decode(file_get_contents($baseline.'/coverage-reconciliation-installation.json'),true);$same=[];$order=fn($t)=>in_array($t,['sr_outbox','sr_deliveries'],true)?'event_id':(in_array($t,['sc_catalogue_meta','pd_due_meta','fo_meta','fs_meta','rm_meta','hp_meta'],true)?'policy':(in_array($t,['hr_qa_profiles','sgf_presentation_profiles'],true)?'profile_key':(in_array($t,['fs_results','fs_result_lines'],true)?'snapshot_key':($t==='company_memberships'?'user_id':'id'))));foreach($before as$name=>$tables){$db=$name==='host'?$host:$native;foreach($tables as$table=>$json){if($db->table($table)->orderBy($order($table))->when($table==='fs_result_lines',fn($q)=>$q->orderBy('account_id'))->get()->toJson()!==$json)throw new LogicException('Accepted facts changed '.$table);$same[]=$name.'.'.$table;}}
foreach($installation['hashes']as$file=>$sha)if(hash_file('sha256',$s['app'].'/'.$file)!==$sha)throw new LogicException('Installed file changed');$older=json_decode(file_get_contents(json_decode(file_get_contents($s['root'].'/remunerations-backup-pointer.json'),true)['root'].'/preservation-private.json'),true);foreach(['binding'=>'binding.json','single_binding'=>'single-bindings.json','stock_binding'=>'stock-binding.json']as$key=>$file)if(hash_file('sha256',$s['root'].'/'.$file)!==$older[$key])throw new LogicException('Binding changed');if($host->table('hp_versions')->count()!==16||$host->table('hp_commands')->count()!==16||$host->table('rm_versions')->count()!==6||$native->table('gl_journals')->count()!==27)throw new LogicException('Original counts changed');file_put_contents($work.'/coverage-reconciliation-preservation.json',json_encode(['passed'=>true,'identical_original_tables'=>$same,'all_installed_hashes_verified'=>true,'three_bindings_unchanged'=>true,'business_commands_8085'=>0,'new_grants'=>0,'new_profiles'=>0,'new_schema'=>false,'cache_cleared'=>false,'protected_six_targeted'=>false],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'identical_tables'=>count($same),'proof'=>$work.'/coverage-reconciliation-preservation.json']).PHP_EOL;

Some files were not shown because too many files have changed in this diff Show More