Preserva classificacao contabilistica aceite e bases locais
Regista aceitacao funcional do primeiro incremento PGC-NIRF e preserva as dependencias documentais e tecnicas existentes. A validacao do calculo fiscal continua separada e pendente. Sem credenciais, bases operacionais ou publicacao.
@@ -1,5 +1,12 @@
|
||||
# SGF Tempo — instruções locais
|
||||
|
||||
- Em 26/10/2026, após informar que entrou e verificou, o utilizador confirmou a aceitação funcional do primeiro incremento de classificação contabilística e autorizou o encerramento e checkpoint Git exclusivamente local, sem credenciais ou dados locais. Preservar a entrega e os limites de `docs/CATALOGUE_ACCOUNTING_DELIVERY.md`. Esta aceitação não abrange a validação do cálculo fiscal, que continua separada e pendente; não autoriza lançamentos, publicação ou outro incremento.
|
||||
|
||||
- Autorização posterior: primeiro incremento de classificação contabilística do catálogo, PGC-NIRF, sem lançamentos e sem alterar o cálculo fiscal. Categoria é regra-base; só excepção aprovada, vigente e compatível a substitui. Mudança de categoria que afecte classificação exige nova proposta, vigência e validação; não usar edição de serviços para contornar o circuito. Propostas/submissões/decisões/resoluções são imutáveis; correcção gera nova versão e a substituição de uma versão aprovada é explícita. Data de referência é explícita, não reconhecimento/IVA. Conta agregadora não é conta de movimento; subconjunto documental inicial apenas 7/7.2, sem inventar desdobramentos. Fonte e versão preservadas.
|
||||
- As quatro concessões contabilísticas iniciais foram especificamente confirmadas e aplicadas em 26/10/2026, após conferência: conta 1, empresa 1, `catalogue_accounting.view/propose`; conta 2, empresa 1, `catalogue_accounting.view/approve`. Osmane prepara; Samira valida/recusa, sem edição/administração implícita. Nenhuma concessão de unidade ou de gestão destes direitos foi acrescentada. Novas concessões exigem confirmação concreta; não associar privilégios a nomes/emails no código. Ver `docs/CATALOGUE_ACCOUNTING_DELIVERY.md`. A validação do cálculo continua separada e pendente.
|
||||
|
||||
- Autorização posterior: primeiro cálculo delimitado sobre preço sem imposto, uma linha, apenas ensaios sintéticos isolados em `sgf_tempo_test`. Política `SGF-SERVICE-NET-VAT-TRUNCATE2-v1`: base/IVA exactos, IVA documental proposto truncado a duas casas e diferença preservada; total proposto apenas se base for exactamente representável a duas casas. É decisão operacional do utilizador, não obrigação fiscal demonstrada. Nenhum perfil real activado, nenhum direito fiscal derivado de configuração visual, nenhuma fotografia B1 alterada. Ver `docs/SERVICE_CALCULATION_DELIVERY.md`; instruções históricas abaixo não revogam este âmbito posterior.
|
||||
|
||||
- Projecto exclusivo em `C:\laragon\www\sgf-tempo`; não alterar InvesteMoz, os seus dados, configurações, ferramentas ou serviços.
|
||||
- Base de requisitos: SGF_TEMPO_Roteiro_v0_9.docx. Distinguir decisões acordadas de propostas técnicas e autorização de execução.
|
||||
- Usar `scripts/sgf.ps1` para ferramentas e arranque. Não usar o PHP/Composer do PATH por omissão nem alterar o PATH global.
|
||||
@@ -22,3 +29,5 @@
|
||||
- Em 25 de Outubro de 2026, o utilizador aceitou a entrega A no âmbito demonstrado e com os limites de docs/SERVICES_A_DELIVERY.md e autorizou um checkpoint Git exclusivamente local, sem credenciais ou dados locais. Preservar esta base aceite. Não avançar para B/C nem publicar.
|
||||
- Autorização posterior delimitada: B1, apenas confirmação interna dos rascunhos de facturação, fotografia imutável e captura transaccional de eventos/pendências. Mostrar «Confirmado internamente — sem validade fiscal». Não emitir documentos comerciais/fiscais, reconhecer receita, produzir lançamentos, receber, reconciliar, fechar ou publicar. A nova concessão billing_drafts.confirm_internal foi especificamente confirmada para a conta existente ID 1, empresa 1/unidade 1; não implica gestão de novas concessões nem altera a consulta da conta ID 2. B1 não conclui B integralmente; ver docs/SERVICES_B1_DELIVERY.md.
|
||||
- Em 25 de Outubro de 2026, o utilizador aceitou B1 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos, e autorizou um checkpoint Git local sem credenciais ou dados locais. Preservar B1 e os checkpoints anteriores. Não avançar para os restantes incrementos nem publicar.
|
||||
- Autorização posterior de 26 de Outubro de 2026: preparação documental de emitente/clientes/rascunhos e instruções de pagamento reutilizáveis do emitente, sem emissão, recebimento ou contabilização. Preservar B1 `8fcd52c`; não reinterpretar preços antigos ou modificar fotografias existentes. Esquema 2 apenas em novas confirmações que utilizem preparação documental. Novos direitos issuer_documents/client_documents view/edit na empresa e draft_documents view/edit na unidade são explícitos; as nove concessões para contas existentes 1 (consulta/edição) e 2 (consulta), empresa 1/unidade 1, foram especificamente confirmadas. Não inferir poderes de gestão desses direitos. Nunca incluir valores completos das instruções em logs técnicos, relatórios de provas ou repositório. Ver docs/DOCUMENT_PREPARATION_DELIVERY.md.
|
||||
- Autorização posterior: primeira pré-visualização apenas no navegador, aviso fixo «RASCUNHO — SEM VALIDADE FISCAL», configuração delimitada e história B1 exclusivamente da fotografia. Não implementar emissão, cálculos fiscais, PDF/exportação/impressão documental ou operações financeiras. Consultas de pré-visualização são coerentes e read-only; mudanças geram aviso e actualização explícita. Novo direito document_preview.configure requer confirmação específica antes de concessão real; a operação para conta 1/empresa 1 foi especificamente confirmada e aplicada em 26 de Outubro de 2026. Samira permanece apenas em consulta. Novas operações não podem ser aplicadas por inferência. Ver docs/DRAFT_INVOICE_PREVIEW_DELIVERY.md.
|
||||
|
||||
@@ -6,7 +6,25 @@ Primeiro percurso funcional local, sobre a fundação existente e o roteiro v0.9
|
||||
|
||||
**Entrega A aceite em 25 de Outubro de 2026:** serviços em rascunho, no âmbito demonstrado e com os limites de [SERVICES_A_DELIVERY.md](docs/SERVICES_A_DELIVERY.md). B/C, operações financeiras confirmadas e publicação continuam sem autorização. O checkpoint aceite `26b80df` permanece preservado.
|
||||
|
||||
## Usar a entrega A
|
||||
## Preparação documental (incremento posterior)
|
||||
|
||||
**Pré-visualização no navegador:** em **Rascunhos de facturação**, abrir um rascunho não confirmado e escolher **Pré-visualizar rascunho**. O aviso de ausência de validade fiscal e as pendências são obrigatórios. Mudanças geram aviso; **Actualizar pré-visualização** carrega explicitamente a nova versão. Para registos confirmados, usar **Abrir confirmação interna histórica**, que não reconstrói dados ausentes. A apresentação consulta-se em **Apresentação do rascunho**; Osmane tem configuração especificamente confirmada e aplicada na Tempo Lda; Samira apenas consulta. Sair e voltar a entrar, se necessário, para carregar o novo direito. [Relatório da pré-visualização](docs/DRAFT_INVOICE_PREVIEW_DELIVERY.md). Sem PDF, impressão documental ou emissão fiscal.
|
||||
|
||||
Depois de entrar e seleccionar a empresa/unidade autorizadas, abrir **Preparação documental** no menu lateral:
|
||||
|
||||
1. **Dados do emitente**: completar NUIT, morada, localidade e país. A denominação é o nome já existente; altera-se no menu Empresa, conforme direitos próprios.
|
||||
2. **Dados dos clientes**: seleccionar um cliente existente e completar os mesmos campos uma vez. Continua a ser possível criar clientes apenas com nome no menu Clientes.
|
||||
3. **Instruções de pagamento**: escolher **Novas instruções** ou uma configuração existente. Preparar banco/titular/identificadores/moeda quando aplicáveis e texto. São instruções do emitente para facilitar o pagamento, sem registo de recebimento ou ligação a bancos. Não introduzir credenciais, tokens, PIN ou dados de cartões.
|
||||
4. **Preparação de prestações** ou **Preparação de facturação**: seleccionar um rascunho já criado nos menus existentes. Preparar moeda, unidades das linhas, data/período, condições, vencimento proposto e modo de preço. Seleccionar instruções configuradas por designação; guardar.
|
||||
5. Consultar **Completude e validações** e os dados relacionados do emitente/cliente. «Preenchido» não é «validado». Condições/vencimento continuam sujeitos a conferência, sem correcção automática.
|
||||
|
||||
Ao criar um novo rascunho de facturação a partir de uma prestação com preparação actual, os dados documentais e selecções são reutilizados. Preparar uma moeda diferente actualiza explicitamente a moeda do rascunho pela operação existente, mantendo valores exactos; escolher «imposto incluído/acrescido» não recalcula impostos nem reinterpreta preços. Se um rascunho for editado depois, rever e guardar novamente a sua preparação. Documentos B1 confirmados permanecem bloqueados.
|
||||
|
||||
Osmane tem os direitos documentais especificamente confirmados na Tempo Lda/SEGI; Samira apenas consulta. Para carregar as novas concessões, actualizar o navegador e, se necessário, sair e voltar a entrar. Sem rascunhos, as listas de preparação estarão vazias: este ecrã reutiliza os registos existentes, não cria uma factura.
|
||||
|
||||
[Relatório e limites da preparação documental](docs/DOCUMENT_PREPARATION_DELIVERY.md). Não há emissão, numeração fiscal, PDF de factura, recebimentos ou lançamentos. Dados bancários apenas introduzidos pelo utilizador não são apresentados como verificados pelo banco.
|
||||
|
||||
## Usar a entrega A e B1
|
||||
|
||||
**Incremento posterior B1:** disponível a confirmação interna do rascunho de facturação para quem possua `billing_drafts.confirm_internal`. Abrir o rascunho, escolher **Rever confirmação interna**, conferir versão/contexto e confirmar. Quantidades/preços e referências devem estar completos e actuais; moeda ou regras desconhecidas permanecem pendentes. A fotografia é imutável e o rascunho confirmado deixa de poder ser editado ou eliminado. A referência `INT-B1-…` não é numeração fiscal. Não há emissão, impressão de factura, reconhecimento ou lançamento contabilístico. [Relatório B1](docs/SERVICES_B1_DELIVERY.md). A mantém-se aceite no checkpoint `ca13acb`; B1 foi aceite em 25 de Outubro de 2026 no âmbito demonstrado, sem validade fiscal nem lançamentos contabilísticos. Não avançar para os restantes incrementos nem publicar.
|
||||
|
||||
@@ -83,3 +101,7 @@ Os testes destrutivos usam exclusivamente `sgf_tempo_test`, verificada antes da
|
||||
Para Vite, `sgf.ps1 frontend` (porta 5173, estrita). A aplicação continua a ser aberta na porta 8000. As ferramentas usam caminhos explícitos em `C:\laragon\tools\sgf-tempo`; o cluster fica em `C:\laragon\data\sgf-tempo\postgres-17`, porta 55432. Não alterar as versões ou o ambiente do InvesteMoz.
|
||||
|
||||
[Relatório do incremento e provas](docs/IDENTITY_DELIVERY.md) · [Registo histórico da preparação inicial](docs/LOCAL_ENVIRONMENT.md)
|
||||
|
||||
O primeiro núcleo de cálculo de serviço e a política operacional de truncagem estão documentados em [cálculo de serviço: utilização, provas e limites](docs/SERVICE_CALCULATION_DELIVERY.md). A aplicação normal conserva pendências de aplicabilidade; cálculos de ensaio são exclusivos da instância isolada de testes. Não há activação de perfil fiscal real, emissão, recebimento ou contabilização.
|
||||
|
||||
O menu **Classificação contabilística**, disponível após actualizar a sessão com os acessos especificamente concedidos, permite preparar e submeter versões PGC-NIRF, validar/recusar sem editar e consultar classificação por data. Categoria e excepções são empresariais; operações e respectivos históricos continuam limitados às unidades autorizadas. As tabelas reais começam vazias, sem contas de movimento ou regras aprovadas por omissão. Consultar [utilização, provas e limites da classificação](docs/CATALOGUE_ACCOUNTING_DELIVERY.md).
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class CatalogueAccounting
|
||||
{
|
||||
public const SOURCE_VERSION='Decreto70/2009-PGC-NIRF-PDF134-BR372(148)';
|
||||
public const SOURCE_HASH='45C38AED64DE43042ED048586ACCE2EC660B74FC3F8FBCE33D39AAB45C7529E3';
|
||||
public const REFERENCE=['plan'=>'PGC-NIRF','source_version'=>self::SOURCE_VERSION,'sha256'=>self::SOURCE_HASH,'accounts'=>[['code'=>'7','name'=>'Rendimentos e ganhos','movable'=>false],['code'=>'7.2','name'=>'Prestação de serviços','movable'=>false]],'status'=>'Exemplar fornecido; consolidação posterior não comprovada; não é aprovação de conta empresarial.'];
|
||||
public const NOTICE='CLASSIFICAÇÃO CONTABILÍSTICA — SEM LANÇAMENTOS; IVA E RECONHECIMENTO INDEPENDENTES';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions,private ServiceWorkflow $workflow) {}
|
||||
|
||||
private function scope(int $actor,string $action='view'): array
|
||||
{
|
||||
$ids=$this->context->identifiers(); abort_unless($this->context->userId()===$actor,403);
|
||||
foreach (array_unique(['view',$action]) as $a) { $this->permissions->require($actor,$ids['tenant_id'],$ids['company_id'],"catalogue_accounting.$a"); }
|
||||
return $ids;
|
||||
}
|
||||
private function query(array $scope) { return DB::table('ca_proposals')->where($scope); }
|
||||
private function row(int $id,array $scope): object { $row=$this->query($scope)->where('id',$id)->first(); abort_unless($row,403,'Proposta fora da empresa autorizada.'); return $row; }
|
||||
private function decode(object $row): array
|
||||
{
|
||||
$result=(array)$row; $result['data']=json_decode($row->data,true,32,JSON_THROW_ON_ERROR);
|
||||
$result['submission']=DB::table('ca_submissions')->where('proposal_id',$row->id)->first();
|
||||
$result['decision']=DB::table('ca_decisions')->where('proposal_id',$row->id)->first();
|
||||
$result['state']=$result['decision']->decision ?? ($result['submission'] ? 'submitted' : 'draft');
|
||||
return $result;
|
||||
}
|
||||
public function listing(int $actor): array
|
||||
{
|
||||
$scope=$this->scope($actor); $rows=$this->query($scope)->orderBy('id')->get()->map(fn($r)=>$this->decode($r))->all();
|
||||
$services=$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'services.view') ? DB::table('a_services')->where($scope)->orderBy('name')->get(['id','version','name'])->all() : [];
|
||||
return ['notice'=>self::NOTICE,'reference'=>self::REFERENCE,'proposals'=>$rows,'services'=>$services];
|
||||
}
|
||||
private function command(int $actor,string $action,array $request,string $key,callable $write): array
|
||||
{
|
||||
$scope=$this->scope($actor,$action); $unit=$this->context->unitId();
|
||||
abort_unless((bool)preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422,'Chave de repetição necessária.');
|
||||
$canonical=function($v) use (&$canonical) { if(!is_array($v)) return $v; if(!array_is_list($v))ksort($v); return array_map($canonical,$v); };
|
||||
$hash=hash('sha256',json_encode($canonical($request),JSON_THROW_ON_ERROR));
|
||||
return DB::transaction(function() use($actor,$action,$request,$key,$write,$scope,$unit,$hash) {
|
||||
DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->context->selectFor($actor,$scope['tenant_id'],$scope['company_id'],$unit); $this->scope($actor,$action);
|
||||
$old=DB::table('ca_commands')->where($scope)->where('command_key',$key)->first();
|
||||
if($old) { abort_unless((int)$old->actor_id===$actor && hash_equals($old->request_hash,$hash),409,'Repetição com autor ou conteúdo diferente.'); return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true]; }
|
||||
$result=$write($scope);
|
||||
DB::table('ca_commands')->insert($scope+['actor_id'=>$actor,'command_key'=>$key,'request_hash'=>$hash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||
return $result+['replayed'=>false];
|
||||
});
|
||||
}
|
||||
private function date(string $date): void { Validator::make(['date'=>$date],['date'=>'required|date_format:Y-m-d'])->validate(); }
|
||||
private function data(string $kind,array $data,array $scope): array
|
||||
{
|
||||
$schemas=[
|
||||
'plan'=>['plan'=>'required|in:PGC-NIRF','source_version'=>'required|in:'.self::SOURCE_VERSION],
|
||||
'account'=>['plan_id'=>'required|integer|min:1','code'=>'required|string|max:40|regex:/^7\.2(?:\.[0-9A-Za-z_-]+)*$/D','name'=>'required|string|max:255','origin'=>'required|in:reference,enterprise','movable'=>'required|boolean','evidence'=>'required|string|max:2000'],
|
||||
'category'=>['name'=>'required|string|max:255','account_id'=>'required|integer|min:1','conditions'=>'required|string|max:2000'],
|
||||
'service_link'=>['service_id'=>'required|integer|min:1','category_id'=>'required|integer|min:1'],
|
||||
'service_override'=>['service_id'=>'required|integer|min:1','account_id'=>'required|integer|min:1','conditions'=>'required|string|max:2000'],
|
||||
];
|
||||
abort_unless(isset($schemas[$kind]),422); abort_if(array_diff(array_keys($data),array_keys($schemas[$kind]))!==[],422,'Campo fora do âmbito: sem IVA, reconhecimento ou lançamentos.');
|
||||
$data=Validator::make($data,$schemas[$kind])->validate();
|
||||
foreach(['plan_id'=>'plan','account_id'=>'account','category_id'=>'category'] as $field=>$type) {
|
||||
if(isset($data[$field])) { $data[$field]=(int)$data[$field]; abort_unless($this->row($data[$field],$scope)->kind===$type,422,'Tipo de referência incompatível.'); }
|
||||
}
|
||||
if(isset($data['service_id'])) {
|
||||
$data['service_id']=(int)$data['service_id'];
|
||||
$this->permissions->require($this->context->userId(),$scope['tenant_id'],$scope['company_id'],'services.view');
|
||||
abort_unless(DB::table('a_services')->where($scope)->where('id',$data['service_id'])->exists(),403,'Serviço fora da empresa.');
|
||||
}
|
||||
if($kind==='account') {
|
||||
$data['movable']=(bool)$data['movable'];
|
||||
if($data['origin']==='reference') { abort_unless($data['code']==='7.2' && !$data['movable'] && $data['name']==='Prestação de serviços',422,'Referência 7.2 não é conta de movimento aprovada.'); }
|
||||
else { abort_if(in_array($data['code'],['7.2','7.2.1','7.2.6'],true),422,'Código reservado; não usar como desdobramento empresarial.'); }
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
public function propose(int $actor,array $input,string $key): array
|
||||
{
|
||||
return $this->command($actor,'propose',['operation'=>'propose','input'=>$input],$key,function($scope) use($actor,$input) {
|
||||
$data=$this->data($input['kind'],$input['data'],$scope); $previous=isset($input['previous_id']) ? $this->row((int)$input['previous_id'],$scope) : null;
|
||||
$entity=match($input['kind']) { 'plan'=>'PGC-NIRF','account'=>$data['code'],'service_link','service_override'=>'service:'.$data['service_id'],default=>$previous?->entity_key ?? (string)Str::uuid() };
|
||||
if($previous) { abort_unless($previous->kind===$input['kind'] && $previous->entity_key===$entity,422,'A correcção deve preservar a identidade.'); }
|
||||
$latest=$this->query($scope)->where('kind',$input['kind'])->where('entity_key',$entity)->orderByDesc('version')->first();
|
||||
abort_unless(($latest?->id ?? null)===($previous?->id ?? null),409,'Existe uma versão mais recente; reveja antes de propor.');
|
||||
$replaces=$input['replaces_id'] ?? null;
|
||||
if($replaces) { $r=$this->row((int)$replaces,$scope); abort_unless($r->kind===$input['kind'] && $r->entity_key===$entity && $r->valid_from<=$input['valid_from'] && $this->approved($r),422,'Substituição exige versão aprovada da mesma identidade e data compatível.'); }
|
||||
$id=DB::table('ca_proposals')->insertGetId($scope+['kind'=>$input['kind'],'entity_key'=>$entity,'version'=>(int)($latest?->version ?? 0)+1,'previous_id'=>$previous?->id,'replaces_id'=>$replaces,
|
||||
'valid_from'=>$input['valid_from'],'valid_until'=>$input['valid_until'] ?? null,'data'=>json_encode($data,JSON_THROW_ON_ERROR),'reason'=>$input['reason'],'actor_id'=>$actor,'created_at'=>now()]);
|
||||
Audit::record('catalogue_accounting.proposed',$actor,$scope['tenant_id'],$scope['company_id'],details:['record_id'=>$id,'version'=>(int)($latest?->version ?? 0)+1]);
|
||||
return ['id'=>$id];
|
||||
});
|
||||
}
|
||||
private function approved(object $row): bool { return DB::table('ca_decisions')->where('proposal_id',$row->id)->where('decision','approved')->exists(); }
|
||||
private function approvedRows(array $scope,string $kind,?string $entity=null): array
|
||||
{
|
||||
$q=$this->query($scope)->where('kind',$kind)->whereExists(fn($q)=>$q->selectRaw('1')->from('ca_decisions')->whereColumn('ca_decisions.proposal_id','ca_proposals.id')->where('decision','approved'));
|
||||
if($entity!==null)$q->where('entity_key',$entity); return $q->get()->all();
|
||||
}
|
||||
private function active(object $row,string $date,array $scope): bool
|
||||
{
|
||||
if($row->valid_from>$date || ($row->valid_until && $row->valid_until<$date))return false;
|
||||
// Explicit approved replacement ends the replaced interval from its start onward.
|
||||
return ! $this->query($scope)->where('replaces_id',$row->id)->where('valid_from','<=',$date)->whereExists(fn($q)=>$q->selectRaw('1')->from('ca_decisions')->whereColumn('ca_decisions.proposal_id','ca_proposals.id')->where('decision','approved'))->exists();
|
||||
}
|
||||
private function select(array $scope,string $kind,string $entity,string $date): ?object
|
||||
{
|
||||
$rows=array_values(array_filter($this->approvedRows($scope,$kind,$entity),fn($r)=>$this->active($r,$date,$scope)));
|
||||
abort_if(count($rows)>1,409,'Conflito explícito: várias versões aprovadas vigentes.'); return $rows[0] ?? null;
|
||||
}
|
||||
private function reference(int $id,string $kind,string $date,array $scope): object
|
||||
{
|
||||
$r=$this->row($id,$scope); abort_unless($r->kind===$kind && $this->approved($r) && $this->active($r,$date,$scope),422,'Referência sem aprovação/vigência compatível.'); return $r;
|
||||
}
|
||||
private function dependencies(object $row,string $date,array $scope): array
|
||||
{
|
||||
$data=json_decode($row->data,true,32,JSON_THROW_ON_ERROR);
|
||||
if($row->kind==='plan')return ['plan'=>$row];
|
||||
if($row->kind==='account') { return ['account'=>$row,'plan'=>$this->reference($data['plan_id'],'plan',$date,$scope)]; }
|
||||
if($row->kind==='service_link') {
|
||||
$categoryRef=$this->row($data['category_id'],$scope);
|
||||
$category=$this->select($scope,'category',$categoryRef->entity_key,$date);
|
||||
abort_unless($category,422,'Categoria sem regra-base aprovada vigente.');
|
||||
return ['link'=>$row]+$this->dependencies($category,$date,$scope);
|
||||
}
|
||||
$account=$this->reference($data['account_id'],'account',$date,$scope);
|
||||
$accountData=json_decode($account->data,true,32,JSON_THROW_ON_ERROR);
|
||||
abort_unless($accountData['movable'],422,'Conta agregadora: classificação de movimento pendente.');
|
||||
return [$row->kind==='category' ? 'category' : 'override'=>$row]+$this->dependencies($account,$date,$scope);
|
||||
}
|
||||
public function submit(int $id,int $actor,string $key): array
|
||||
{
|
||||
return $this->command($actor,'propose',['operation'=>'submit','id'=>$id],$key,function($scope) use($actor,$id) {
|
||||
$r=$this->row($id,$scope); $latest=$this->query($scope)->where('kind',$r->kind)->where('entity_key',$r->entity_key)->max('version');
|
||||
abort_unless((int)$latest===(int)$r->version,409,'Submeta a versão mais recente.');
|
||||
abort_if(DB::table('ca_submissions')->where('proposal_id',$id)->exists(),409,'Versão já submetida.');
|
||||
$this->dependencies($r,$r->valid_from,$scope);
|
||||
DB::table('ca_submissions')->insert($scope+['proposal_id'=>$id,'actor_id'=>$actor,'created_at'=>now()]);
|
||||
Audit::record('catalogue_accounting.submitted',$actor,$scope['tenant_id'],$scope['company_id'],details:['record_id'=>$id,'version'=>(int)$r->version]); return ['id'=>$id];
|
||||
});
|
||||
}
|
||||
public function decide(int $id,int $actor,array $input,string $key): array
|
||||
{
|
||||
return $this->command($actor,'approve',['operation'=>'decide','id'=>$id,'input'=>$input],$key,function($scope) use($id,$actor,$input) {
|
||||
$r=$this->row($id,$scope); abort_unless(DB::table('ca_submissions')->where('proposal_id',$id)->exists(),409,'Versão não submetida.');
|
||||
abort_if(DB::table('ca_decisions')->where('proposal_id',$id)->exists(),409,'Versão já decidida.');
|
||||
abort_unless((int)$this->query($scope)->where('kind',$r->kind)->where('entity_key',$r->entity_key)->max('version')===(int)$r->version,409,'Uma nova proposta exige revisão da versão actual.');
|
||||
if($input['decision']==='approved') {
|
||||
$this->dependencies($r,$r->valid_from,$scope);
|
||||
foreach($this->approvedRows($scope,$r->kind,$r->entity_key) as $other) {
|
||||
// Conflicting overlapping intervals require an explicit replacement, never latest-wins.
|
||||
$start=max($r->valid_from,$other->valid_from); $end=min($r->valid_until ?? '9999-12-31',$other->valid_until ?? '9999-12-31');
|
||||
if($start<=$end && $this->active($other,$start,$scope)) { abort_unless((int)$r->replaces_id===(int)$other->id,409,'Sobreposição: indique explicitamente a versão aprovada substituída.'); }
|
||||
}
|
||||
}
|
||||
DB::table('ca_decisions')->insert($scope+['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$input['decision'],'reason'=>$input['reason'],'created_at'=>now()]);
|
||||
Audit::record('catalogue_accounting.'.$input['decision'],$actor,$scope['tenant_id'],$scope['company_id'],details:['record_id'=>$id,'version'=>(int)$r->version,'reason'=>$input['reason']]); return ['id'=>$id,'decision'=>$input['decision']];
|
||||
});
|
||||
}
|
||||
private function resolveInternal(int $service,string $date,array $scope): array
|
||||
{
|
||||
$s=DB::table('a_services')->where($scope)->where('id',$service)->first(); abort_unless($s,403,'Serviço fora da empresa.');
|
||||
$base=['service'=>['id'=>$service,'version'=>(int)$s->version,'name'=>$s->name],'reference_date'=>$date,'notice'=>self::NOTICE];
|
||||
$override=$this->select($scope,'service_override','service:'.$service,$date);
|
||||
$link=$this->select($scope,'service_link','service:'.$service,$date);
|
||||
if(!$override && !$link)return $base+['state'=>'pending','pending'=>['Ligação à categoria ou excepção aprovada vigente em falta.']];
|
||||
try { $refs=$this->dependencies($override ?? $link,$date,$scope); }
|
||||
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { if($e->getStatusCode()!==422)throw $e; return $base+['state'=>'pending','pending'=>[$e->getMessage()],'conflict'=>true]; }
|
||||
$plan=$this->select($scope,'plan','PGC-NIRF',$date);
|
||||
if(!$plan || (int)$plan->id!==(int)$refs['plan']->id)return $base+['state'=>'pending','pending'=>['Versão do plano incompatível; rever associações.'],'conflict'=>true];
|
||||
return $base+['state'=>'resolved_classification_only','origin'=>$override ? 'explicit_service_exception' : 'category_base','references'=>array_map(fn($r)=>['id'=>$r->id,'version'=>(int)$r->version,'kind'=>$r->kind,'entity_key'=>$r->entity_key,'valid_from'=>$r->valid_from,'valid_until'=>$r->valid_until,'data'=>json_decode($r->data,true),'approval'=>(array)DB::table('ca_decisions')->where('proposal_id',$r->id)->first()],$refs),
|
||||
'source'=>self::REFERENCE,'pending'=>['IVA e reconhecimento continuam independentes; nenhum lançamento produzido.']];
|
||||
}
|
||||
public function resolve(int $service,?string $date,int $actor): array
|
||||
{
|
||||
$scope=$this->scope($actor); $this->permissions->require($actor,$scope['tenant_id'],$scope['company_id'],'services.view');
|
||||
abort_unless(DB::table('a_services')->where($scope)->where('id',$service)->exists(),403);
|
||||
if(!$date)return ['state'=>'pending','pending'=>['Data de referência explícita em falta; nenhuma data foi presumida.']]; $this->date($date);
|
||||
$read=function()use($service,$date,$scope){$result=$this->resolveInternal($service,$date,$scope);return $result+['revision'=>hash_hmac('sha256',json_encode($result,JSON_THROW_ON_ERROR),(string)config('app.key'))];};
|
||||
if(DB::transactionLevel())return $read();
|
||||
return DB::transaction(function()use($read){DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $read();});
|
||||
}
|
||||
public function capture(int $actor,array $input,string $key): array
|
||||
{
|
||||
return $this->command($actor,'propose',['operation'=>'capture','input'=>$input],$key,function($scope) use($actor,$input) {
|
||||
$operationScope=$this->workflow->scope($input['area'],$actor,'view');
|
||||
$draft=$this->workflow->show($input['area'],$input['record_id'],$actor);
|
||||
abort_unless($draft['version']===$input['record_version'],409,'Operação desactualizada.');
|
||||
abort_if(($draft['internal_confirmation'] ?? null)!==null,409,'B1 preservado; não capturar classificação retroactiva.');
|
||||
abort_unless(in_array($input['service_id'],array_column($draft['data']['lines'],'service_id'),true),422,'Serviço não pertence à operação.');
|
||||
$snapshot=$this->resolve($input['service_id'],$input['reference_date'],$actor);
|
||||
abort_unless(hash_equals($snapshot['revision'],$input['resolution_revision']),409,'A classificação mudou desde a consulta; consulte novamente antes de guardar.');
|
||||
abort_unless($snapshot['state']==='resolved_classification_only',422,implode(' ',$snapshot['pending']));
|
||||
$id=DB::table('ca_resolutions')->insertGetId($operationScope+['area'=>$input['area'],'record_id'=>$input['record_id'],'record_version'=>$input['record_version'],'service_id'=>$input['service_id'],'reference_date'=>$input['reference_date'],'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'actor_id'=>$actor,'created_at'=>now()]);
|
||||
Audit::record('catalogue_accounting.resolution_captured',$actor,$scope['tenant_id'],$scope['company_id'],$operationScope['unit_id'],details:['record_id'=>$id,'version'=>$input['record_version']]); return ['id'=>$id];
|
||||
});
|
||||
}
|
||||
public function history(int $actor,string $area,int $record): array
|
||||
{
|
||||
$this->scope($actor); $scope=$this->workflow->scope($area,$actor,'view'); $this->workflow->show($area,$record,$actor);
|
||||
return DB::table('ca_resolutions')->where($scope)->where('area',$area)->where('record_id',$record)->orderByDesc('id')->get()->map(function($r) { $r->snapshot=json_decode($r->snapshot,true); return $r; })->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class DocumentPreparation
|
||||
{
|
||||
public const PROFILE_FIELDS = ['nuit','address','locality','country'];
|
||||
public const PAYMENT_FIELDS = ['label','bank','holder','account_number','nib','iban','swift','bank_code','currency','instructions'];
|
||||
public const DRAFT_FIELDS = ['service_date','period_start','period_end','payment_terms','proposed_due_date','price_mode','units','payment_instruction_ids'];
|
||||
|
||||
public function __construct(private CompanyContext $context, private Permissions $permissions, private ServiceWorkflow $workflow) {}
|
||||
|
||||
public function scope(string $kind, int $actor, string $action): array
|
||||
{
|
||||
abort_unless(in_array($kind, ['issuer','client','payment','service_drafts','billing_drafts'], true), 404);
|
||||
$ids = $this->context->identifiers(); abort_unless($this->context->userId() === $actor, 403);
|
||||
$unit = str_ends_with($kind, '_drafts') ? $this->context->unitId() : null;
|
||||
abort_if(str_ends_with($kind, '_drafts') && ! $unit, 403, 'Seleccione uma unidade autorizada.');
|
||||
$prefix = $kind === 'client' ? 'client_documents' : ($unit ? 'draft_documents' : 'issuer_documents');
|
||||
foreach (array_unique(['view',$action]) as $right) { $this->permissions->require($actor, $ids['tenant_id'], $ids['company_id'], "$prefix.$right", $unit); }
|
||||
return $ids + ['unit_id' => $unit];
|
||||
}
|
||||
|
||||
private function query(string $kind, array $scope)
|
||||
{
|
||||
return DB::table('doc_records')->where($scope)->where('kind', $kind);
|
||||
}
|
||||
|
||||
private function target(string $kind, int $id, array $scope): ?object
|
||||
{
|
||||
if ($kind === 'payment') { return $id ? $this->query($kind, $scope)->where('id', $id)->first() : null; }
|
||||
$table = $kind === 'issuer' ? 'companies' : ($kind === 'client' ? 'a_clients' : 'a_'.$kind);
|
||||
$query = DB::table($table)->where('tenant_id', $scope['tenant_id'])->where($kind === 'issuer' ? 'id' : 'company_id', $scope['company_id'])->where('id', $id);
|
||||
if ($scope['unit_id']) { $query->where('unit_id', $scope['unit_id']); }
|
||||
$target = $query->first(); abort_unless($target, 403, 'Registo fora do âmbito autorizado.'); return $target;
|
||||
}
|
||||
|
||||
public function index(string $kind, int $actor): array
|
||||
{
|
||||
$scope = $this->scope($kind, $actor, 'view');
|
||||
if ($kind === 'payment') { return $this->query($kind, $scope)->orderBy('id')->get()->map(fn ($row) => $this->decode($row))->all(); }
|
||||
if ($kind === 'issuer') { return [['id' => $scope['company_id'], 'name' => DB::table('companies')->where('id', $scope['company_id'])->value('name')]]; }
|
||||
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); return DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->orderBy('name')->get(['id','name'])->all(); }
|
||||
return $this->workflow->list($kind, $actor);
|
||||
}
|
||||
|
||||
public function show(string $kind, int $id, int $actor): array
|
||||
{
|
||||
$scope = $this->scope($kind, $actor, 'view'); $target = $this->target($kind, $id, $scope);
|
||||
abort_if($kind === 'payment' && ! $target, 403);
|
||||
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); }
|
||||
if ($scope['unit_id']) { $this->workflow->scope($kind, $actor, 'view'); }
|
||||
$row = $kind === 'payment' ? $target : $this->query($kind, $scope)->where('record_id', $id)->first();
|
||||
$record = $row ? $this->decode($row) : ['id' => null, 'version' => 0, 'data' => []];
|
||||
$record['name'] = $target->name ?? ($record['data']['label'] ?? '');
|
||||
$record['origin'] = $kind === 'payment' ? 'Instruções configuradas do emitente' : ($kind === 'issuer' ? 'Nome reutilizado do cadastro da empresa' : ($kind === 'client' ? 'Nome reutilizado do cadastro do cliente' : 'Rascunho existente na unidade seleccionada'));
|
||||
$record['locked'] = $kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists();
|
||||
$record['review'] = $this->review($kind, $record['data'], $target);
|
||||
if ($scope['unit_id']) {
|
||||
$data = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
|
||||
$record['draft_version'] = (int) $target->version; $record['currency'] = $data['currency']; $record['lines'] = $data['lines'];
|
||||
$record['review'][] = $this->check('currency', 'Moeda do rascunho', $data['currency'], 'Rascunho existente', 'Indicar moeda sem a presumir; validar aplicabilidade.');
|
||||
$record['review'][] = ['field' => 'terms_due', 'label' => 'Condições e vencimento', 'state' => 'validation_pending', 'origin' => 'Preparação do rascunho', 'next' => 'Conferir prazo e vencimento proposto. Sem emissão efectiva ou convenção de contagem aprovada, a compatibilidade não é validada nem corrigida automaticamente.'];
|
||||
if ($row && (int) ($record['data']['draft_version'] ?? 0) !== (int) $target->version) { $record['review'][] = ['field' => 'draft_version', 'label' => 'Rascunho alterado', 'state' => 'stale', 'origin' => 'Versão de origem', 'next' => 'Rever linhas, unidades e preparação antes de voltar a guardar.']; }
|
||||
$record['related_profiles'] = [];
|
||||
foreach (['issuer' => $scope['company_id'], 'client' => $target->client_id] as $relatedKind => $relatedId) {
|
||||
$prefix = $relatedKind === 'issuer' ? 'issuer_documents.view' : 'client_documents.view';
|
||||
if ($this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], $prefix)) { $related = $this->show($relatedKind, (int) $relatedId, $actor); $record['related_profiles'][$relatedKind] = ['name' => $related['name'], 'version' => $related['version'], 'review' => $related['review']]; }
|
||||
}
|
||||
}
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function save(string $kind, int $id, int $actor, array $input): array
|
||||
{
|
||||
$scope = $this->scope($kind, $actor, 'edit'); $selected = $this->context->unitId();
|
||||
try {
|
||||
return DB::transaction(function () use ($kind, $id, $actor, $input, $scope, $selected): array {
|
||||
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selected); $this->scope($kind, $actor, 'edit');
|
||||
$target = $this->target($kind, $id, $scope); abort_if($kind === 'payment' && $id && ! $target, 403);
|
||||
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); }
|
||||
$old = $kind === 'payment' ? $target : $this->query($kind, $scope)->where('record_id', $id)->first();
|
||||
abort_unless((int) ($old->version ?? 0) === $input['version'], 409, 'Os dados mudaram; reabra o formulário.');
|
||||
$fields = in_array($kind, ['issuer','client'], true) ? self::PROFILE_FIELDS : ($kind === 'payment' ? self::PAYMENT_FIELDS : self::DRAFT_FIELDS);
|
||||
$data = array_intersect_key($input, array_flip($fields));
|
||||
if ($scope['unit_id']) {
|
||||
$this->workflow->scope($kind, $actor, 'view');
|
||||
abort_if($kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'A fotografia e o rascunho B1 estão bloqueados.');
|
||||
abort_unless((int) $target->version === $input['draft_version'], 409, 'O rascunho mudou; reveja a preparação.');
|
||||
$draftData = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
|
||||
abort_unless(count($data['units']) === count($draftData['lines']), 422, 'Indique uma unidade de medida, ou pendência, por linha existente.');
|
||||
$data['payment_instruction_ids'] = array_values(array_unique($data['payment_instruction_ids']));
|
||||
if ($data['payment_instruction_ids']) { $this->scope('payment', $actor, 'view'); }
|
||||
foreach ($data['payment_instruction_ids'] as $instruction) { abort_unless($this->query('payment', ['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'unit_id' => null])->where('id', $instruction)->exists(), 403, 'Instrução fora da empresa autorizada.'); }
|
||||
if ($input['currency'] !== $draftData['currency']) {
|
||||
$changes = ['version' => (int) $target->version, 'currency' => $input['currency']];
|
||||
if ($kind === 'service_drafts') { $changes += ['client_id' => $target->client_id, 'lines' => $draftData['lines']]; }
|
||||
$saved = $this->workflow->save($kind, $id, $actor, $changes, (string) \Illuminate\Support\Str::uuid());
|
||||
$target->version = $saved['version'];
|
||||
}
|
||||
$data['draft_version'] = (int) $target->version;
|
||||
}
|
||||
$version = (int) ($old->version ?? 0) + 1;
|
||||
$values = ['data' => json_encode((object) $data, JSON_THROW_ON_ERROR), 'version' => $version, 'updated_at' => now()];
|
||||
if ($old) { $this->query($kind, $scope)->where('id', $old->id)->update($values); $record = $old->id; }
|
||||
else { $record = DB::table('doc_records')->insertGetId($scope + $values + ['kind' => $kind, 'record_id' => $kind === 'payment' ? 0 : $id, 'created_at' => now()]); }
|
||||
DB::table('doc_history')->insert(['doc_record_id' => $record, 'version' => $version, 'actor_id' => $actor, 'snapshot' => $values['data'], 'created_at' => now()]);
|
||||
// Audit contains only identifiers/version; never bank identifiers or instructions.
|
||||
Audit::record('documents.'.$kind.'.prepared', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => $kind, 'record_id' => $record, 'version' => $version]);
|
||||
return ['id' => $kind === 'payment' ? $record : $id, 'version' => $version];
|
||||
});
|
||||
} catch (QueryException) { throw new HttpException(500, 'Não foi possível guardar a preparação. Nenhum valor das instruções foi incluído no erro; reabra e tente novamente.'); }
|
||||
}
|
||||
|
||||
public function snapshot(int $draft, int $actor): ?array
|
||||
{
|
||||
$scope = $this->context->identifiers(); $scope['unit_id'] = $this->context->unitId();
|
||||
$billing = DB::table('a_billing_drafts')->where($scope)->where('id', $draft)->firstOrFail();
|
||||
$rows = DB::table('doc_records')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where(function ($q) use ($billing, $draft) {
|
||||
$q->where('kind', 'issuer')->orWhere(fn ($q) => $q->where('kind', 'client')->where('record_id', $billing->client_id))->orWhere(fn ($q) => $q->where('kind', 'billing_drafts')->where('record_id', $draft));
|
||||
})->get();
|
||||
if ($rows->isEmpty()) { return null; }
|
||||
foreach (['issuer','client','billing_drafts'] as $kind) { $this->scope($kind, $actor, 'view'); }
|
||||
$result = ['schema_version' => 2, 'records' => [], 'payment_instructions' => [], 'completeness' => [], 'validation_status' => 'not_fiscally_validated'];
|
||||
foreach ($rows as $row) {
|
||||
$decoded = $this->decode($row); $result['records'][] = $decoded;
|
||||
$result['completeness'][$row->kind] = $this->show($row->kind, (int) $row->record_id, $actor)['review'];
|
||||
if ($row->kind === 'billing_drafts') {
|
||||
abort_unless((int) ($decoded['data']['draft_version'] ?? 0) === (int) $billing->version, 409, 'Reveja a preparação documental após a alteração do rascunho.');
|
||||
foreach ($decoded['data']['payment_instruction_ids'] as $id) { $result['payment_instructions'][] = $this->show('payment', $id, $actor); }
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function inheritToBilling(int $source, int $billing, int $actor, array $scope): void
|
||||
{
|
||||
if (! $this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], 'draft_documents.view', $scope['unit_id'])) { return; }
|
||||
$row = $this->query('service_drafts', $scope)->where('record_id', $source)->first();
|
||||
if (! $row) { return; }
|
||||
$data = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
|
||||
if ((int) $data['draft_version'] !== (int) DB::table('a_service_drafts')->where('id', $source)->value('version')) { return; }
|
||||
$data['draft_version'] = 1; $data['source_preparation'] = ['id' => $row->id, 'version' => $row->version];
|
||||
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
|
||||
$id = DB::table('doc_records')->insertGetId($scope + ['kind' => 'billing_drafts', 'record_id' => $billing, 'version' => 1, 'data' => $encoded, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('doc_history')->insert(['doc_record_id' => $id, 'version' => 1, 'actor_id' => $actor, 'snapshot' => $encoded, 'created_at' => now()]);
|
||||
Audit::record('documents.billing_drafts.reused', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['record_id' => $id, 'version' => 1]);
|
||||
}
|
||||
|
||||
private function decode(object $row): array { $result = (array) $row; $result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR); return $result; }
|
||||
private function check(string $field, string $label, mixed $value, string $origin, string $next): array { return ['field' => $field, 'label' => $label, 'state' => $value !== null && $value !== '' ? 'filled_not_validated' : 'missing', 'origin' => $origin, 'next' => $next]; }
|
||||
private function review(string $kind, array $data, ?object $target): array
|
||||
{
|
||||
$fields = in_array($kind, ['issuer','client'], true) ? self::PROFILE_FIELDS : ($kind === 'payment' ? ['label','bank','holder','currency','instructions'] : ['service_date','period_start','period_end','payment_terms','proposed_due_date','price_mode']);
|
||||
$labels = ['nuit' => 'NUIT', 'address' => 'Morada', 'locality' => 'Localidade', 'country' => 'País', 'label' => 'Designação', 'bank' => 'Banco (quando aplicável)', 'holder' => 'Titular (quando aplicável)', 'currency' => 'Moeda (quando aplicável)', 'instructions' => 'Instruções', 'service_date' => 'Data do serviço', 'period_start' => 'Início do período', 'period_end' => 'Fim do período', 'payment_terms' => 'Condições de pagamento', 'proposed_due_date' => 'Vencimento proposto', 'price_mode' => 'Modo de preço'];
|
||||
$result = [];
|
||||
if (in_array($kind, ['issuer','client'], true)) { $result[] = $this->check('name','Denominação', $target?->name, 'Cadastro existente (sem duplicação)', 'Editar o nome no cadastro original se necessário; validar denominação legal.'); }
|
||||
foreach ($fields as $field) { $value = $data[$field] ?? null; if ($field === 'price_mode' && $value === 'undefined') { $value = null; } $result[] = $this->check($field, $labels[$field], $value, 'Preparação introduzida pelo utilizador', 'Completar se aplicável e validar; preenchido não significa fiscalmente validado.'); }
|
||||
if (str_ends_with($kind, '_drafts')) { foreach ($data['units'] ?? [] as $i => $value) { $result[] = $this->check('unit_'.$i,'Unidade da linha '.($i + 1),$value,'Preparação da linha existente','Indicar unidade de medida se aplicável; nenhuma unidade é presumida.'); } }
|
||||
$result[] = ['field' => 'legal', 'label' => 'Validação legal/fiscal', 'state' => 'validation_pending', 'origin' => 'Regras ainda por validar', 'next' => 'Validar fontes e regras aplicáveis. Este ecrã não autoriza emissão fiscal nem verifica dados junto de bancos.'];
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class DocumentPreview
|
||||
{
|
||||
public const CONFIGURE = 'document_preview.configure';
|
||||
public const NOTICE = 'RASCUNHO — SEM VALIDADE FISCAL';
|
||||
public const DEFAULT_MODEL = ['show_logo' => false, 'show_contacts' => true, 'show_observations' => true, 'show_payment_instructions' => true, 'contact_text' => '', 'observations' => ''];
|
||||
|
||||
public function __construct(private CompanyContext $context, private Permissions $permissions, private DocumentPreparation $preparation, private ServiceWorkflow $workflow) {}
|
||||
|
||||
private function consistent(callable $read): mixed
|
||||
{
|
||||
if (DB::transactionLevel() > 0) {
|
||||
// RefreshDatabase fixtures already use a transaction. Production composition must start its own snapshot.
|
||||
if (! app()->environment('testing')) { throw new HttpException(409, 'Reabra a consulta numa operação independente.'); }
|
||||
return $read();
|
||||
}
|
||||
return DB::transaction(function () use ($read) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
return $read();
|
||||
});
|
||||
}
|
||||
|
||||
private function model(): array
|
||||
{
|
||||
$ids = $this->context->identifiers();
|
||||
$row = DB::table('document_preview_models')->where($ids)->first();
|
||||
return ['version' => (int) ($row->version ?? 0), 'data' => $row ? json_decode($row->data, true, 32, JSON_THROW_ON_ERROR) : self::DEFAULT_MODEL];
|
||||
}
|
||||
|
||||
public function configuration(int $actor): array
|
||||
{
|
||||
return $this->consistent(function () use ($actor) { $this->preparation->scope('issuer', $actor, 'view'); return $this->model() + ['local_logo_available' => $this->logo() !== null]; });
|
||||
}
|
||||
|
||||
public function configure(int $actor, array $input): array
|
||||
{
|
||||
$scope = $this->preparation->scope('issuer', $actor, 'view'); $unit = $this->context->unitId();
|
||||
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE);
|
||||
try { return DB::transaction(function () use ($scope, $unit, $actor, $input) {
|
||||
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $unit);
|
||||
$this->preparation->scope('issuer', $actor, 'view');
|
||||
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE);
|
||||
$ids = ['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id']];
|
||||
$old = DB::table('document_preview_models')->where($ids)->first();
|
||||
abort_unless((int) ($old->version ?? 0) === $input['version'], 409, 'A configuração mudou. Reabra-a antes de guardar.');
|
||||
$data = array_intersect_key($input, self::DEFAULT_MODEL); $version = (int) ($old->version ?? 0) + 1;
|
||||
$values = ['version' => $version, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()];
|
||||
if ($old) { DB::table('document_preview_models')->where('id', $old->id)->update($values); $id = $old->id; }
|
||||
else { $id = DB::table('document_preview_models')->insertGetId($ids + $values + ['created_at' => now()]); }
|
||||
DB::table('document_preview_model_history')->insert(['model_id' => $id, 'version' => $version, 'actor_id' => $actor, 'snapshot' => $values['data'], 'created_at' => now()]);
|
||||
Audit::record('document_preview.configured', $actor, $scope['tenant_id'], $scope['company_id'], details: ['record_id' => $id, 'version' => $version]);
|
||||
return ['version' => $version];
|
||||
}); } catch (QueryException) { throw new HttpException(500, 'Não foi possível guardar a configuração; nenhum conteúdo foi incluído no erro.'); }
|
||||
}
|
||||
|
||||
public function read(int $draft, int $actor, bool $historical = false): array
|
||||
{
|
||||
return $this->consistent(function () use ($draft, $actor, $historical) {
|
||||
$scope = $this->workflow->scope('billing_drafts', $actor, 'view');
|
||||
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
if ($historical) { $payload = $this->historical($draft, $actor, $scope); }
|
||||
else { $payload = $this->current($draft, $actor, $scope); }
|
||||
$payload['notice'] = self::NOTICE;
|
||||
// Fingerprint is keyed, so a change token cannot be used to guess bank identifiers.
|
||||
$revision = hash_hmac('sha256', json_encode($payload, JSON_THROW_ON_ERROR), (string) config('app.key'));
|
||||
return $payload + ['revision' => $revision, 'consulted_at' => now()->toIso8601String()];
|
||||
});
|
||||
}
|
||||
|
||||
private function current(int $draft, int $actor, array $scope): array
|
||||
{
|
||||
foreach (['issuer','client','billing_drafts'] as $kind) { $this->preparation->scope($kind, $actor, 'view'); }
|
||||
$billing = $this->workflow->show('billing_drafts', $draft, $actor);
|
||||
abort_if($billing['internal_confirmation'] !== null, 409, 'Este registo tem confirmação interna. Abra a vista histórica separada.');
|
||||
$issuer = $this->preparation->show('issuer', $scope['company_id'], $actor);
|
||||
$client = $this->preparation->show('client', $billing['client_id'], $actor);
|
||||
$prepared = $this->preparation->show('billing_drafts', $draft, $actor);
|
||||
$model = $this->model(); $payments = []; $paymentVersions = [];
|
||||
foreach ($prepared['data']['payment_instruction_ids'] ?? [] as $id) {
|
||||
$payment = $this->preparation->show('payment', (int) $id, $actor); $paymentVersions[] = [$payment['id'], $payment['version']];
|
||||
if ($model['data']['show_payment_instructions']) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; }
|
||||
}
|
||||
$pending = $billing['pending'];
|
||||
foreach (['Emitente' => $issuer, 'Cliente' => $client, 'Preparação' => $prepared] as $section => $row) {
|
||||
foreach ($row['review'] as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$section.'.'.$check['field']] = $section.' — '.$check['label'].': '.$check['next']; } }
|
||||
}
|
||||
if (! $paymentVersions) { $pending['instructions_missing'] = 'Instruções de pagamento não seleccionadas; preparar apenas as aplicáveis.'; }
|
||||
if (! $model['data']['contact_text']) { $pending['contacts_missing'] = 'Contactos de apresentação não configurados; completar se aplicável, independentemente da visibilidade do bloco.'; }
|
||||
$localLogo = $this->logo(); $logo = $model['data']['show_logo'] ? $localLogo : null;
|
||||
if (! $localLogo) { $pending['logo_missing'] = 'Logótipo opcional não disponível: falta recurso local autorizado da empresa. A escolha de visibilidade não valida este recurso.'; }
|
||||
return ['mode' => 'draft', 'title' => 'Pré-visualização do rascunho de factura de serviço', 'reference' => 'Rascunho interno #'.$draft,
|
||||
'scope' => $scope, 'source_id' => $billing['source_id'], 'draft_version' => $billing['version'], 'preparation_version' => $prepared['version'], 'model_version' => $model['version'],
|
||||
'issuer' => ['name' => $issuer['name'], 'data' => $issuer['data']], 'client' => ['name' => $client['name'], 'data' => $client['data']],
|
||||
'data' => $billing['data'], 'prepared' => $prepared['data'], 'base_amount' => $billing['base_amount'], 'pending' => $pending,
|
||||
'calculation' => app(ServiceCalculation::class)->read($draft, $actor),
|
||||
'payments' => $payments, 'logo' => $logo, 'contacts' => $model['data']['show_contacts'] ? $model['data']['contact_text'] : null,
|
||||
'observations' => $model['data']['show_observations'] ? $model['data']['observations'] : null,
|
||||
'versions' => ['issuer' => $issuer['version'], 'client' => $client['version'], 'company_name' => $issuer['name'], 'client_name' => $client['name'], 'instructions' => $paymentVersions, 'model' => $model['version']]];
|
||||
}
|
||||
|
||||
private function historical(int $draft, int $actor, array $scope): array
|
||||
{
|
||||
$confirmation = app(InternalConfirmation::class)->show($draft, $actor);
|
||||
abort_unless($confirmation, 404, 'Não há confirmação interna histórica deste registo.');
|
||||
$snapshot = $confirmation['snapshot']; $prepared = []; $issuer = []; $client = []; $payments = []; $pending = $snapshot['pending'];
|
||||
$document = $snapshot['document_preparation'] ?? [];
|
||||
foreach ($document['records'] ?? [] as $record) {
|
||||
if ($record['kind'] === 'issuer') { $issuer = $record['data']; }
|
||||
if ($record['kind'] === 'client') { $client = $record['data']; }
|
||||
if ($record['kind'] === 'billing_drafts') { $prepared = $record['data']; }
|
||||
}
|
||||
foreach ($document['payment_instructions'] ?? [] as $payment) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; }
|
||||
foreach ($document['completeness'] ?? [] as $kind => $checks) { foreach ($checks as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$kind.'.'.$check['field']] = $check['label'].': '.$check['next']; } } }
|
||||
$pending['historical_limits'] = 'Logótipo, contactos, observações do modelo e denominação do emitente não foram capturados nesta fotografia. Não foram reconstruídos.';
|
||||
return ['mode' => 'historical', 'title' => 'Confirmação interna histórica — sem validade fiscal', 'reference' => $confirmation['internal_reference'], 'scope' => $scope,
|
||||
'confirmed_at' => $snapshot['confirmed_at'], 'actor_id' => $snapshot['actor_id'], 'snapshot_schema' => $snapshot['schema_version'] ?? 1, 'source_id' => $snapshot['service_draft']['id'],
|
||||
'draft_version' => $snapshot['billing_draft']['version'], 'preparation_version' => null, 'model_version' => null,
|
||||
'issuer' => ['name' => null, 'data' => $issuer], 'client' => ['name' => $snapshot['client']['name'], 'data' => $client],
|
||||
'data' => $snapshot['data'], 'prepared' => $prepared, 'base_amount' => $snapshot['base_amount'], 'pending' => $pending, 'payments' => $payments,
|
||||
'logo' => null, 'contacts' => null, 'observations' => null];
|
||||
}
|
||||
|
||||
private function logo(): ?string
|
||||
{
|
||||
$company = $this->context->identifiers()['company_id'];
|
||||
$disk = Storage::disk('local'); $path = 'document-logos/company-'.$company.'.png';
|
||||
if (! $disk->exists($path) || $disk->size($path) > 524288) { return null; }
|
||||
$bytes = $disk->get($path);
|
||||
if (! str_starts_with($bytes, "\x89PNG\r\n\x1a\n") || (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) !== 'image/png') { return null; }
|
||||
return 'data:image/png;base64,'.base64_encode($bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Services\CatalogueAccounting;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class CatalogueAccountingController extends Controller
|
||||
{
|
||||
private function response(array $data) { return response()->json($data)->header('Cache-Control','no-store, private'); }
|
||||
private function only(Request $r,array $keys): void { abort_if(array_diff(array_keys($r->all()),$keys)!==[],422,'Campo protegido ou fora do âmbito.'); }
|
||||
public function index(Request $r,CatalogueAccounting $c) { return $this->response($c->listing($r->user()->id)); }
|
||||
public function propose(Request $r,CatalogueAccounting $c) {
|
||||
$this->only($r,['kind','data','valid_from','valid_until','reason','previous_id','replaces_id']);
|
||||
$data=$r->validate(['kind'=>'required|in:plan,account,category,service_link,service_override','data'=>'required|array','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','reason'=>'required|string|max:2000','previous_id'=>'nullable|integer|min:1','replaces_id'=>'nullable|integer|min:1']);
|
||||
return $this->response($c->propose($r->user()->id,$data,(string)$r->header('Idempotency-Key')));
|
||||
}
|
||||
public function submit(Request $r,int $id,CatalogueAccounting $c) { $this->only($r,[]); return $this->response($c->submit($id,$r->user()->id,(string)$r->header('Idempotency-Key'))); }
|
||||
public function decide(Request $r,int $id,CatalogueAccounting $c) {
|
||||
$this->only($r,['decision','reason']); return $this->response($c->decide($id,$r->user()->id,$r->validate(['decision'=>'required|in:approved,refused','reason'=>'required|string|max:2000']),(string)$r->header('Idempotency-Key')));
|
||||
}
|
||||
public function resolve(Request $r,int $service,CatalogueAccounting $c) { $this->only($r,['reference_date']); $r->validate(['reference_date'=>'nullable|date_format:Y-m-d']); return $this->response($c->resolve($service,$r->query('reference_date'),$r->user()->id)); }
|
||||
public function capture(Request $r,CatalogueAccounting $c) {
|
||||
$this->only($r,['area','record_id','record_version','service_id','reference_date','resolution_revision']);
|
||||
$data=$r->validate(['area'=>'required|in:service_drafts,billing_drafts','record_id'=>'required|integer|min:1','record_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','reference_date'=>'required|date_format:Y-m-d','resolution_revision'=>'required|string|regex:/^[a-f0-9]{64}$/D']);
|
||||
foreach(['record_id','record_version','service_id'] as $field)$data[$field]=(int)$data[$field];
|
||||
return $this->response($c->capture($r->user()->id,$data,(string)$r->header('Idempotency-Key')));
|
||||
}
|
||||
public function history(Request $r,string $area,int $record,CatalogueAccounting $c) { abort_unless(in_array($area,['service_drafts','billing_drafts'],true),404); return $this->response(['records'=>$c->history($r->user()->id,$area,$record)]); }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Services\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Services\DocumentPreparation;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class DocumentPreparationController extends Controller
|
||||
{
|
||||
public function index(Request $request, string $kind, DocumentPreparation $preparation) { return response()->json(['records' => $preparation->index($kind, $request->user()->id)]); }
|
||||
public function show(Request $request, string $kind, int $id, DocumentPreparation $preparation) { return response()->json($preparation->show($kind, $id, $request->user()->id)); }
|
||||
public function save(Request $request, string $kind, int $id, DocumentPreparation $preparation)
|
||||
{
|
||||
$preparation->scope($kind, $request->user()->id, 'edit');
|
||||
$fields = in_array($kind, ['issuer','client'], true) ? DocumentPreparation::PROFILE_FIELDS : ($kind === 'payment' ? DocumentPreparation::PAYMENT_FIELDS : DocumentPreparation::DRAFT_FIELDS);
|
||||
$allowed = array_merge($fields, ['version'], str_ends_with($kind, '_drafts') ? ['draft_version','currency'] : []);
|
||||
abort_if(array_diff(array_keys($request->all()), $allowed) !== [], 422, 'Campo não suportado; não introduza credenciais, tokens ou dados de cartões.');
|
||||
$rules = ['version' => 'required|integer|min:0'];
|
||||
foreach ($fields as $field) { $rules[$field] = 'nullable|string|max:500'; }
|
||||
if ($kind === 'payment') {
|
||||
$rules['label'] = 'required|string|max:120'; $rules['instructions'] = 'nullable|string|max:2000'; $rules['currency'] = 'nullable|string|regex:/^[A-Z]{3}$/';
|
||||
} elseif (str_ends_with($kind, '_drafts')) {
|
||||
foreach (['service_date','period_start','period_end','proposed_due_date'] as $field) { $rules[$field] = 'nullable|date_format:Y-m-d'; }
|
||||
if ($request->filled('period_start')) { $rules['period_end'] .= '|after_or_equal:period_start'; }
|
||||
$rules['payment_terms'] = 'nullable|string|max:1000';
|
||||
$rules['price_mode'] = 'required|in:undefined,included,added';
|
||||
$rules['units'] = 'required|array|min:1|max:100'; $rules['units.*'] = 'nullable|string|max:80';
|
||||
$rules['payment_instruction_ids'] = 'present|array|max:20'; $rules['payment_instruction_ids.*'] = 'required|integer|min:1|distinct';
|
||||
$rules['draft_version'] = 'required|integer|min:1'; $rules['currency'] = 'nullable|string|regex:/^[A-Z]{3}$/';
|
||||
}
|
||||
$data = $request->validate($rules); $data['version'] = (int) $data['version'];
|
||||
foreach ($fields as $field) { if (! array_key_exists($field, $data)) { $data[$field] = null; } }
|
||||
if (isset($data['draft_version'])) { $data['draft_version'] = (int) $data['draft_version']; $data['currency'] ??= null; $data['payment_instruction_ids'] = array_map('intval', $data['payment_instruction_ids']); }
|
||||
// Refuse obvious secret assignments in free text; no secret fields are supported.
|
||||
foreach ($data 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 nas instruções.'); } }
|
||||
return response()->json($preparation->save($kind, $id, $request->user()->id, $data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Services\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Services\DocumentPreview;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class DocumentPreviewController extends Controller
|
||||
{
|
||||
public function calculate(Request $request, int $id, \App\Modules\Services\ServiceCalculation $calculation)
|
||||
{
|
||||
abort_if(array_diff(array_keys($request->all()), ['draft_version','preparation_version','profile_id','profile_version']) !== [], 422);
|
||||
$data = $request->validate(['draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0','profile_id'=>'required|integer|min:1','profile_version'=>'required|integer|min:1']);
|
||||
return $this->response($calculation->calculate($id,$request->user()->id,array_map('intval',$data)));
|
||||
}
|
||||
private function response(array $data) { return response()->json($data)->header('Cache-Control', 'no-store, private, max-age=0')->header('Pragma', 'no-cache')->header('Vary', 'Cookie'); }
|
||||
public function model(Request $request, DocumentPreview $preview) { return $this->response($preview->configuration($request->user()->id)); }
|
||||
public function configure(Request $request, DocumentPreview $preview)
|
||||
{
|
||||
abort_if(array_diff(array_keys($request->all()), ['version', ...array_keys(DocumentPreview::DEFAULT_MODEL)]) !== [], 422, 'Campo não configurável: aviso e pendências são obrigatórios.');
|
||||
$rules = ['version' => 'required|integer|min:0', 'contact_text' => 'nullable|string|max:2000', 'observations' => 'nullable|string|max:2000'];
|
||||
foreach (['show_logo','show_contacts','show_observations','show_payment_instructions'] as $field) { $rules[$field] = 'required|boolean'; }
|
||||
$data = $request->validate($rules); $data['version'] = (int) $data['version'];
|
||||
foreach (['show_logo','show_contacts','show_observations','show_payment_instructions'] as $field) { $data[$field] = (bool) $data[$field]; }
|
||||
$data['contact_text'] ??= ''; $data['observations'] ??= '';
|
||||
return $this->response($preview->configure($request->user()->id, $data));
|
||||
}
|
||||
public function draft(Request $request, int $id, DocumentPreview $preview) { return $this->response($preview->read($id, $request->user()->id)); }
|
||||
public function historical(Request $request, int $id, DocumentPreview $preview) { return $this->response($preview->read($id, $request->user()->id, true)); }
|
||||
public function revision(Request $request, string $mode, int $id, DocumentPreview $preview)
|
||||
{
|
||||
abort_unless(in_array($mode, ['draft','history'], true), 404);
|
||||
$result = $preview->read($id, $request->user()->id, $mode === 'history');
|
||||
return $this->response(['revision' => $result['revision']]);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ final class InternalConfirmation
|
||||
if (! $row) { return null; }
|
||||
$history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $row->id)->value('id');
|
||||
$event = DB::table('a_events as e')->join('a_outbox as o', 'e.id', '=', 'o.event_id')->where('history_id', $history)->first(['e.id','e.type','o.state']);
|
||||
return ['id' => $row->id, 'internal_reference' => 'INT-B1-'.$row->id, 'label' => self::LABEL, 'snapshot' => json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR), 'event' => $event];
|
||||
$snapshot = json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR);
|
||||
if (isset($snapshot['document_preparation'])) { foreach (['issuer','client','billing_drafts'] as $kind) { app(DocumentPreparation::class)->scope($kind, $actor, 'view'); } }
|
||||
return ['id' => $row->id, 'internal_reference' => 'INT-B1-'.$row->id, 'label' => self::LABEL, 'snapshot' => $snapshot, 'event' => $event];
|
||||
}
|
||||
|
||||
public function confirm(int $draft, int $actor, int $version, string $key): array
|
||||
@@ -30,7 +32,7 @@ final class InternalConfirmation
|
||||
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']);
|
||||
abort_unless((bool) preg_match('/^[a-zA-Z0-9-]{8,100}$/D', $key), 422, 'É necessária uma chave de repetição válida.');
|
||||
$hash = hash('sha256', json_encode(['b1.confirm_internal', $draft, $scope, $version], JSON_THROW_ON_ERROR));
|
||||
return DB::transaction(function () use ($scope, $draft, $actor, $version, $key, $hash): array {
|
||||
try { return DB::transaction(function () use ($scope, $draft, $actor, $version, $key, $hash): array {
|
||||
DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
$this->workflow->scope('billing_drafts', $actor, 'view');
|
||||
@@ -64,17 +66,19 @@ final class InternalConfirmation
|
||||
$snapshot = ['label' => self::LABEL, 'scope' => $scope, 'billing_draft' => $this->record($billing), 'service_draft' => $this->record($source),
|
||||
'client' => $this->record($client), 'services' => $catalogue, 'data' => $data, 'base_amount' => DraftValues::base($data),
|
||||
'pending' => $pending, 'actor_id' => $actor, 'confirmed_at' => now()->toIso8601String(), 'accounting_status' => 'blocked_pending', 'fiscal_status' => 'not_issued'];
|
||||
$preparation = app(DocumentPreparation::class)->snapshot($draft, $actor);
|
||||
if ($preparation !== null) { $snapshot['schema_version'] = 2; $snapshot['document_preparation'] = $preparation; }
|
||||
$id = DB::table('b_internal_confirmations')->insertGetId($scope + ['billing_draft_id' => $draft, 'billing_version' => $version, 'version' => 1, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
$history = DB::table('a_history')->insertGetId($scope + ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
foreach ($pending as $code => $message) { DB::table('a_pending')->insert(['history_id' => $history, 'code' => $code, 'message' => $message, 'created_at' => now()]); }
|
||||
$event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => 'b1.billing_draft.confirmed_internal', 'schema_version' => 1, 'payload' => json_encode(['confirmation_id' => $id, 'snapshot' => $snapshot], JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
$event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => 'b1.billing_draft.confirmed_internal', 'schema_version' => $preparation !== null ? 2 : 1, 'payload' => json_encode(['confirmation_id' => $id, 'snapshot' => $snapshot], JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
DB::table('a_outbox')->insert(['event_id' => $event, 'state' => 'captured', 'created_at' => now()]);
|
||||
Audit::record('b1.billing_draft.confirmed_internal', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, 'event_id' => $event]);
|
||||
$result = ['id' => $id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$id, 'event_id' => $event];
|
||||
}
|
||||
DB::table('a_commands')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'actor_id' => $actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
return $result + ['replayed' => isset($existing) && $existing !== null];
|
||||
});
|
||||
}); } catch (\Illuminate\Database\QueryException) { throw new \Symfony\Component\HttpKernel\Exception\HttpException(500, 'Não foi possível capturar a confirmação. Nenhum dado bancário foi incluído no erro.'); }
|
||||
}
|
||||
|
||||
private function record(object $record): array
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Testing\TestingDatabaseGuard;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ServiceCalculation
|
||||
{
|
||||
public const POLICY = 'SGF-SERVICE-NET-VAT-TRUNCATE2-v1';
|
||||
public const SOURCES = ['InvesteMoz/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md@v1.2', 'Lei22/2022-republicada:17(1)', 'CIVA:15,7,8,17(2)'];
|
||||
|
||||
public function __construct(private ServiceWorkflow $workflow, private DocumentPreparation $preparation, private CompanyContext $context) {}
|
||||
|
||||
public static function isolated(): bool { return app()->environment(['testing','browser-testing']); }
|
||||
|
||||
public static function evaluate(array $data, array $prepared, ?array $profile): array
|
||||
{
|
||||
$pending = [];
|
||||
if (count($data['lines'] ?? []) !== 1) { $pending[] = 'Apenas uma linha de serviço é suportada.'; }
|
||||
if (($prepared['price_mode'] ?? null) !== 'added') { $pending[] = 'É necessário preço sem imposto (acrescido); incluído fica fora deste âmbito.'; }
|
||||
if (!preg_match('/^[A-Z]{3}$/D', $data['currency'] ?? '')) { $pending[] = 'Moeda explícita em falta.'; }
|
||||
if (!$profile || ($profile['kind'] ?? null) !== 'synthetic' || ($profile['policy'] ?? null) !== self::POLICY) { $pending[] = 'Perfil de ensaio e política identificados em falta ou incompatíveis.'; }
|
||||
if (!preg_match('/^\d{1,3}(\.\d{1,8})?$/D', $profile['rate_percent'] ?? '') || BigDecimal::of($profile['rate_percent'] ?? '0')->isLessThanOrEqualTo(0)) { $pending[] = 'Taxa positiva identificada em falta.'; }
|
||||
if (($profile['currency'] ?? null) !== ($data['currency'] ?? null)) { $pending[] = 'Moeda incompatível com o perfil.'; }
|
||||
$line = $data['lines'][0] ?? [];
|
||||
foreach (['quantity','unit_price'] as $field) {
|
||||
if (!is_string($line[$field] ?? null) || !preg_match('/^\d{1,16}(\.\d{1,8})?$/D', $line[$field])) { $pending[] = 'Quantidade/preço ausente ou fora dos limites exactos de A.'; }
|
||||
}
|
||||
if (!$pending && BigDecimal::of($line['quantity'])->isLessThanOrEqualTo(0)) { $pending[] = 'Quantidade deve ser positiva.'; }
|
||||
if ($pending) { return ['state' => 'pending', 'pending' => array_values(array_unique($pending)), 'policy' => self::POLICY]; }
|
||||
$base = BigDecimal::of($line['quantity'])->multipliedBy($line['unit_price']);
|
||||
$exact = $base->multipliedBy($profile['rate_percent'])->multipliedBy('0.01');
|
||||
$document = $exact->toScale(2, RoundingMode::DOWN);
|
||||
$representable = $base->stripTrailingZeros()->getScale() <= 2;
|
||||
return ['state' => $representable ? 'synthetic_proposed' : 'synthetic_base_representation_pending', 'policy' => self::POLICY,
|
||||
'base_exact' => (string) $base->stripTrailingZeros(), 'vat_exact' => (string) $exact->stripTrailingZeros(),
|
||||
'vat_document_proposed' => (string) $document, 'discarded_difference' => (string) $exact->minus($document)->stripTrailingZeros(),
|
||||
'total_document_proposed' => $representable ? (string) $base->plus($document)->toScale(2) : null,
|
||||
'currency' => $data['currency'], 'pending' => $representable ? [] : ['Política de representação da base/total pendente; base não truncada.'],
|
||||
'fiscally_validated' => false, 'policy_basis' => 'Decisão operacional do utilizador; não obrigação legal comprovada.'];
|
||||
}
|
||||
|
||||
private function sources(int $draft, int $actor): array
|
||||
{
|
||||
$billing = $this->workflow->show('billing_drafts', $draft, $actor);
|
||||
abort_if($billing['internal_confirmation'], 409, 'Fotografia B1 preservada; cálculo não altera confirmações.');
|
||||
$prepared = $this->preparation->show('billing_drafts', $draft, $actor);
|
||||
return [$billing, $prepared];
|
||||
}
|
||||
|
||||
private function input(array $b, array $p, object $profile): array
|
||||
{
|
||||
return ['draft_id' => (int) $b['id'], 'draft_version' => $b['version'], 'preparation_version' => $p['version'],
|
||||
'data' => $b['data'], 'prepared' => $p['data'], 'profile_id' => (int) $profile->id,
|
||||
'profile_version' => (int) $profile->version, 'profile_identifier' => $profile->identifier,
|
||||
'profile' => json_decode($profile->definition, true, 32, JSON_THROW_ON_ERROR), 'policy' => self::POLICY,
|
||||
'source_current_version' => (int) DB::table('a_service_drafts')->where('id',$b['source_id'])->where('tenant_id',$b['tenant_id'])->where('company_id',$b['company_id'])->where('unit_id',$b['unit_id'])->value('version')];
|
||||
}
|
||||
private function fingerprint(array $input): string { return hash('sha256', json_encode($input, JSON_THROW_ON_ERROR)); }
|
||||
|
||||
public function calculate(int $draft, int $actor, array $command): array
|
||||
{
|
||||
// Test-only command is deliberately not a fiscal permission or real profile activation path.
|
||||
abort_unless(self::isolated(), 403, 'Cálculo de ensaio restrito ao ambiente isolado de testes.');
|
||||
TestingDatabaseGuard::assertSafe();
|
||||
$scope = $this->workflow->scope('billing_drafts', $actor, 'edit');
|
||||
return DB::transaction(function () use ($draft,$actor,$command,$scope) {
|
||||
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
$this->workflow->scope('billing_drafts', $actor, 'edit');
|
||||
[$b,$p] = $this->sources($draft,$actor);
|
||||
abort_unless($b['version'] === $command['draft_version'] && $p['version'] === $command['preparation_version'], 409, 'Versões desactualizadas.');
|
||||
abort_unless(($p['data']['draft_version'] ?? null) === $b['version'], 409, 'Reveja a preparação após alterar o rascunho.');
|
||||
$profile = DB::table('service_calculation_profiles')->where($scope)->where('id', $command['profile_id'])->first();
|
||||
abort_unless($profile, 403, 'Perfil fora do âmbito autorizado.');
|
||||
$latest = DB::table('service_calculation_profiles')->where($scope)->where('identifier', $profile->identifier)->max('version');
|
||||
abort_unless((int) $latest === (int) $profile->version && (int) $profile->version === $command['profile_version'], 409, 'Perfil desactualizado.');
|
||||
$input = $this->input($b,$p,$profile);
|
||||
abort_unless($input['source_current_version'] === $b['data']['source_version'], 409, 'Prestação de origem desactualizada.');
|
||||
$result = self::evaluate($b['data'],$p['data'],$input['profile']);
|
||||
abort_if($result['state'] === 'pending', 422, implode(' ', $result['pending']));
|
||||
$hash = $this->fingerprint($input);
|
||||
$old = DB::table('service_calculation_results')->where('billing_draft_id',$draft)->where('fingerprint',$hash)->first();
|
||||
if ($old) { return ['id' => $old->id, 'replayed' => true]; }
|
||||
$id = DB::table('service_calculation_results')->insertGetId($scope + ['billing_draft_id'=>$draft, 'profile_id'=>$profile->id, 'actor_id'=>$actor,
|
||||
'fingerprint'=>$hash, 'snapshot'=>json_encode(['input'=>$input,'result'=>$result],JSON_THROW_ON_ERROR), 'created_at'=>now()]);
|
||||
Audit::record('service_calculation.synthetic_created',$actor,$scope['tenant_id'],$scope['company_id'],$scope['unit_id'],details:['result_id'=>$id,'policy'=>self::POLICY]);
|
||||
return ['id'=>$id,'replayed'=>false];
|
||||
});
|
||||
}
|
||||
|
||||
public function read(int $draft, int $actor): array
|
||||
{
|
||||
$scope = $this->workflow->scope('billing_drafts',$actor,'view');
|
||||
if (!self::isolated()) { return ['environment'=>'normal','history'=>[], 'pending'=>['Aplicabilidade à empresa, serviço/localização, base e acontecimento fiscal por demonstrar; nenhum perfil real activado.']]; }
|
||||
TestingDatabaseGuard::assertSafe();
|
||||
[$b,$p] = $this->sources($draft,$actor);
|
||||
$profiles = DB::table('service_calculation_profiles')->where($scope)->orderByDesc('version')->get();
|
||||
$latest = []; foreach ($profiles as $profile) { $latest[$profile->identifier] ??= $profile; }
|
||||
$history = [];
|
||||
foreach (DB::table('service_calculation_results')->where($scope)->where('billing_draft_id',$draft)->orderByDesc('id')->get() as $row) {
|
||||
$snapshot = json_decode($row->snapshot,true,32,JSON_THROW_ON_ERROR);
|
||||
$profile = $latest[$snapshot['input']['profile_identifier']] ?? null;
|
||||
$current = $profile && hash_equals($row->fingerprint,$this->fingerprint($this->input($b,$p,$profile)));
|
||||
$history[] = ['id'=>$row->id,'current'=>(bool)$current,'snapshot'=>$snapshot];
|
||||
}
|
||||
return ['environment'=>'synthetic_test_only','policy'=>self::POLICY,'history'=>$history,
|
||||
'can_calculate'=>app(\App\Modules\Identity\Services\Permissions::class)->has($actor,$scope['tenant_id'],$scope['company_id'],'billing_drafts.edit',$scope['unit_id']),
|
||||
'profiles'=>array_map(fn ($r)=>['id'=>$r->id,'version'=>(int)$r->version,'identifier'=>$r->identifier],array_values($latest)),
|
||||
'draft_version'=>$b['version'],'preparation_version'=>$p['version'],
|
||||
'pending'=>['ENSAIO SINTÉTICO — não valida regime, base tributável, data fiscal ou emissão da empresa real.']];
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ final class ServiceWorkflow
|
||||
if ($id) { $this->query($area, $scope)->where('id', $id)->update($values); }
|
||||
else { $id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]); }
|
||||
$record = $this->query($area, $scope)->where('id', $id)->first();
|
||||
if ($area === 'billing_drafts' && ! $old) { app(DocumentPreparation::class)->inheritToBilling((int) $record->source_id, $id, $actor, $scope); }
|
||||
$snapshot = (array) $record; $snapshot['data'] = $data;
|
||||
$pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : [];
|
||||
if ($area === 'billing_drafts') {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('doc_records', function (Blueprint $table): void {
|
||||
$table->id(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id'); $table->unsignedBigInteger('unit_id')->nullable();
|
||||
$table->string('kind', 30); $table->unsignedBigInteger('record_id'); $table->unsignedInteger('version'); $table->jsonb('data'); $table->timestampsTz();
|
||||
$table->foreign(['tenant_id','company_id'])->references(['tenant_id','id'])->on('companies')->restrictOnDelete();
|
||||
$table->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
|
||||
});
|
||||
DB::statement("CREATE UNIQUE INDEX doc_record_identity ON doc_records(company_id,kind,record_id) WHERE kind <> 'payment'");
|
||||
DB::statement("ALTER TABLE doc_records ADD CHECK (version > 0 AND jsonb_typeof(data) = 'object' AND ((kind IN ('issuer','client','payment') AND unit_id IS NULL) OR (kind IN ('service_drafts','billing_drafts') AND unit_id IS NOT NULL)))");
|
||||
Schema::create('doc_history', function (Blueprint $table): void {
|
||||
$table->id(); $table->foreignId('doc_record_id')->constrained('doc_records')->restrictOnDelete(); $table->unsignedInteger('version');
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete(); $table->jsonb('snapshot'); $table->timestampTz('created_at');
|
||||
$table->unique(['doc_record_id','version']);
|
||||
});
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION sgf_doc_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE item record; target text; instruction jsonb;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'SGF_DOC_NO_DELETE'; END IF;
|
||||
IF TG_OP = 'INSERT' AND NEW.version <> 1 THEN RAISE EXCEPTION 'SGF_DOC_INITIAL_VERSION'; END IF;
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
IF ROW(NEW.id,NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.kind,NEW.record_id) IS DISTINCT FROM ROW(OLD.id,OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.kind,OLD.record_id) THEN RAISE EXCEPTION 'SGF_DOC_IMMUTABLE_SCOPE'; END IF;
|
||||
IF NEW.version <> OLD.version + 1 THEN RAISE EXCEPTION 'SGF_DOC_VERSION'; END IF;
|
||||
END IF;
|
||||
IF NEW.kind = 'issuer' AND NEW.record_id <> NEW.company_id THEN RAISE EXCEPTION 'SGF_DOC_ISSUER'; END IF;
|
||||
IF NEW.kind = 'client' AND NOT EXISTS (SELECT 1 FROM a_clients WHERE id=NEW.record_id AND tenant_id=NEW.tenant_id AND company_id=NEW.company_id) THEN RAISE EXCEPTION 'SGF_DOC_CLIENT'; END IF;
|
||||
IF NEW.kind = 'payment' AND NEW.record_id <> 0 THEN RAISE EXCEPTION 'SGF_DOC_PAYMENT'; END IF;
|
||||
IF NEW.kind IN ('service_drafts','billing_drafts') THEN
|
||||
target := 'a_' || NEW.kind;
|
||||
EXECUTE format('SELECT tenant_id,company_id,unit_id,version FROM %I WHERE id=$1',target) INTO item USING NEW.record_id;
|
||||
IF item IS NULL OR ROW(item.tenant_id,item.company_id,item.unit_id) IS DISTINCT FROM ROW(NEW.tenant_id,NEW.company_id,NEW.unit_id) THEN RAISE EXCEPTION 'SGF_DOC_DRAFT_SCOPE'; END IF;
|
||||
IF NEW.kind = 'billing_drafts' AND EXISTS (SELECT 1 FROM b_internal_confirmations WHERE billing_draft_id=NEW.record_id) THEN RAISE EXCEPTION 'SGF_DOC_CONFIRMED_IMMUTABLE'; END IF;
|
||||
IF (NEW.data->>'draft_version')::integer IS DISTINCT FROM item.version THEN RAISE EXCEPTION 'SGF_DOC_STALE_DRAFT'; END IF;
|
||||
IF NEW.data->>'price_mode' NOT IN ('undefined','included','added') THEN RAISE EXCEPTION 'SGF_DOC_PRICE_MODE'; END IF;
|
||||
FOR instruction IN SELECT * FROM jsonb_array_elements(NEW.data->'payment_instruction_ids') LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM doc_records WHERE id=instruction::text::bigint AND kind='payment' AND tenant_id=NEW.tenant_id AND company_id=NEW.company_id) THEN RAISE EXCEPTION 'SGF_DOC_FOREIGN_PAYMENT'; END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE OR REPLACE FUNCTION sgf_doc_history_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM doc_records WHERE id=NEW.doc_record_id AND version=NEW.version AND data=NEW.snapshot) THEN RAISE EXCEPTION 'SGF_DOC_HISTORY_VERSION'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER doc_record_guard BEFORE INSERT OR UPDATE OR DELETE ON doc_records FOR EACH ROW EXECUTE FUNCTION sgf_doc_guard();
|
||||
CREATE TRIGGER doc_history_guard BEFORE INSERT ON doc_history FOR EACH ROW EXECUTE FUNCTION sgf_doc_history_guard();
|
||||
CREATE TRIGGER doc_history_immutable BEFORE UPDATE OR DELETE ON doc_history FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
|
||||
SQL);
|
||||
$company = ['company.view','company.edit','unit.create','access.manage','issuer_documents.view','issuer_documents.edit','client_documents.view','client_documents.edit'];
|
||||
$unit = ['unit.view','unit.edit','billing_drafts.confirm_internal','draft_documents.view','draft_documents.edit'];
|
||||
foreach (['clients','services','service_drafts','billing_drafts'] as $area) { foreach (['view','create','edit','manage'] as $action) { if (in_array($area, ['clients','services'], true)) { $company[] = "$area.$action"; } else { $unit[] = "$area.$action"; } } }
|
||||
$companySql = "'".implode("','", $company)."'"; $unitSql = "'".implode("','", $unit)."'";
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ($companySql) AND unit_id IS NULL) OR (permission IN ($unitSql) AND unit_id IS NOT NULL))");
|
||||
}
|
||||
public function down(): void { throw new RuntimeException('Document preparation contains historical data; use a separately reviewed preservation procedure.'); }
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('document_preview_models', function (Blueprint $table): void {
|
||||
$table->id(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id')->unique();
|
||||
$table->unsignedInteger('version'); $table->jsonb('data'); $table->timestampsTz();
|
||||
$table->foreign(['tenant_id','company_id'])->references(['tenant_id','id'])->on('companies')->restrictOnDelete();
|
||||
});
|
||||
Schema::create('document_preview_model_history', function (Blueprint $table): void {
|
||||
$table->id(); $table->foreignId('model_id')->constrained('document_preview_models')->restrictOnDelete();
|
||||
$table->unsignedInteger('version'); $table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->jsonb('snapshot'); $table->timestampTz('created_at'); $table->unique(['model_id','version']);
|
||||
});
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION sgf_preview_model_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'SGF_PREVIEW_NO_DELETE'; END IF;
|
||||
IF TG_OP = 'INSERT' AND NEW.version <> 1 THEN RAISE EXCEPTION 'SGF_PREVIEW_INITIAL_VERSION'; END IF;
|
||||
IF TG_OP = 'UPDATE' AND (ROW(NEW.id,NEW.tenant_id,NEW.company_id) IS DISTINCT FROM ROW(OLD.id,OLD.tenant_id,OLD.company_id) OR NEW.version <> OLD.version+1) THEN RAISE EXCEPTION 'SGF_PREVIEW_IMMUTABLE_SCOPE_OR_VERSION'; END IF;
|
||||
IF jsonb_typeof(NEW.data) IS DISTINCT FROM 'object' OR (NEW.data - ARRAY['show_logo','show_contacts','show_observations','show_payment_instructions','contact_text','observations']) <> '{}'::jsonb THEN RAISE EXCEPTION 'SGF_PREVIEW_FIXED_CONTENT'; END IF;
|
||||
IF jsonb_typeof(NEW.data->'show_logo') IS DISTINCT FROM 'boolean' OR jsonb_typeof(NEW.data->'show_contacts') IS DISTINCT FROM 'boolean' OR jsonb_typeof(NEW.data->'show_observations') IS DISTINCT FROM 'boolean' OR jsonb_typeof(NEW.data->'show_payment_instructions') IS DISTINCT FROM 'boolean' THEN RAISE EXCEPTION 'SGF_PREVIEW_BOOLEAN'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE OR REPLACE FUNCTION sgf_preview_history_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM document_preview_models WHERE id=NEW.model_id AND version=NEW.version AND data=NEW.snapshot) THEN RAISE EXCEPTION 'SGF_PREVIEW_HISTORY'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER preview_model_guard BEFORE INSERT OR UPDATE OR DELETE ON document_preview_models FOR EACH ROW EXECUTE FUNCTION sgf_preview_model_guard();
|
||||
CREATE TRIGGER preview_model_history_guard BEFORE INSERT ON document_preview_model_history FOR EACH ROW EXECUTE FUNCTION sgf_preview_history_guard();
|
||||
CREATE TRIGGER preview_model_history_immutable BEFORE UPDATE OR DELETE ON document_preview_model_history FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
|
||||
SQL);
|
||||
$company = ['company.view','company.edit','unit.create','access.manage','issuer_documents.view','issuer_documents.edit','client_documents.view','client_documents.edit','document_preview.configure'];
|
||||
$unit = ['unit.view','unit.edit','billing_drafts.confirm_internal','draft_documents.view','draft_documents.edit'];
|
||||
foreach (['clients','services','service_drafts','billing_drafts'] as $area) { foreach (['view','create','edit','manage'] as $action) { if (in_array($area, ['clients','services'], true)) { $company[] = "$area.$action"; } else { $unit[] = "$area.$action"; } } }
|
||||
$companySql = "'".implode("','", $company)."'"; $unitSql = "'".implode("','", $unit)."'";
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ($companySql) AND unit_id IS NULL) OR (permission IN ($unitSql) AND unit_id IS NOT NULL))");
|
||||
}
|
||||
public function down(): void { throw new RuntimeException('Preview model history requires a separately reviewed preservation procedure.'); }
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('service_calculation_profiles', function (Blueprint $t): void {
|
||||
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
|
||||
$t->string('identifier'); $t->unsignedInteger('version'); $t->jsonb('definition'); $t->timestampTz('created_at');
|
||||
$t->unique(['unit_id','identifier','version']);
|
||||
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
|
||||
});
|
||||
Schema::create('service_calculation_results', function (Blueprint $t): void {
|
||||
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
|
||||
$t->foreignId('billing_draft_id')->constrained('a_billing_drafts')->restrictOnDelete();
|
||||
$t->foreignId('profile_id')->constrained('service_calculation_profiles')->restrictOnDelete();
|
||||
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$t->string('fingerprint', 64); $t->jsonb('snapshot'); $t->timestampTz('created_at');
|
||||
$t->unique(['billing_draft_id','fingerprint']);
|
||||
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
|
||||
});
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION sgf_calculation_insert_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=NEW.billing_draft_id AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id)
|
||||
OR NOT EXISTS(SELECT 1 FROM service_calculation_profiles p WHERE p.id=NEW.profile_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id)
|
||||
THEN RAISE EXCEPTION 'SGF_CALCULATION_SCOPE'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER calculation_scope BEFORE INSERT ON service_calculation_results FOR EACH ROW EXECUTE FUNCTION sgf_calculation_insert_guard();
|
||||
CREATE TRIGGER calculation_results_immutable BEFORE UPDATE OR DELETE ON service_calculation_results FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
|
||||
CREATE TRIGGER calculation_profiles_immutable BEFORE UPDATE OR DELETE ON service_calculation_profiles FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
|
||||
SQL);
|
||||
}
|
||||
public function down(): void { throw new RuntimeException('Calculation history requires a reviewed preservation procedure.'); }
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ca_proposals', function (Blueprint $t): void {
|
||||
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id');
|
||||
$t->string('kind',30); $t->string('entity_key',100); $t->unsignedInteger('version');
|
||||
$t->foreignId('previous_id')->nullable()->constrained('ca_proposals')->restrictOnDelete();
|
||||
$t->foreignId('replaces_id')->nullable()->constrained('ca_proposals')->restrictOnDelete();
|
||||
$t->date('valid_from'); $t->date('valid_until')->nullable(); $t->jsonb('data');
|
||||
$t->text('reason'); $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); $t->timestampTz('created_at');
|
||||
$t->unique(['company_id','kind','entity_key','version']); $t->unique(['tenant_id','company_id','id']);
|
||||
$t->foreign(['tenant_id','company_id'])->references(['tenant_id','id'])->on('companies')->restrictOnDelete();
|
||||
});
|
||||
foreach (['ca_submissions','ca_decisions'] as $table) {
|
||||
Schema::create($table,function (Blueprint $t) use ($table): void {
|
||||
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('proposal_id')->unique();
|
||||
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); $t->timestampTz('created_at');
|
||||
if ($table==='ca_decisions') { $t->string('decision',15); $t->text('reason'); }
|
||||
$t->foreign(['tenant_id','company_id','proposal_id'])->references(['tenant_id','company_id','id'])->on('ca_proposals')->restrictOnDelete();
|
||||
});
|
||||
}
|
||||
Schema::create('ca_resolutions',function (Blueprint $t): void {
|
||||
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
|
||||
$t->string('area',30); $t->unsignedBigInteger('record_id'); $t->unsignedInteger('record_version'); $t->unsignedBigInteger('service_id');
|
||||
$t->date('reference_date'); $t->jsonb('snapshot'); $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); $t->timestampTz('created_at');
|
||||
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
|
||||
$t->foreign(['tenant_id','company_id','service_id'])->references(['tenant_id','company_id','id'])->on('a_services')->restrictOnDelete();
|
||||
});
|
||||
Schema::create('ca_commands',function (Blueprint $t): void {
|
||||
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$t->string('command_key',100); $t->string('request_hash',64); $t->jsonb('result'); $t->timestampTz('created_at');
|
||||
$t->unique(['company_id','command_key']);
|
||||
$t->foreign(['tenant_id','company_id'])->references(['tenant_id','id'])->on('companies')->restrictOnDelete();
|
||||
});
|
||||
DB::unprepared(<<<'SQL'
|
||||
ALTER TABLE ca_proposals ADD CHECK (kind IN ('plan','account','category','service_link','service_override'));
|
||||
ALTER TABLE ca_proposals ADD CHECK (valid_until IS NULL OR valid_until >= valid_from);
|
||||
ALTER TABLE ca_decisions ADD CHECK (decision IN ('approved','refused'));
|
||||
CREATE OR REPLACE FUNCTION sgf_ca_proposal_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r record; ref text; keys text[];
|
||||
BEGIN
|
||||
IF NEW.previous_id IS NULL THEN
|
||||
IF NEW.version<>1 THEN RAISE EXCEPTION 'SGF_CA_INITIAL_VERSION'; END IF;
|
||||
ELSE
|
||||
SELECT * INTO r FROM ca_proposals WHERE id=NEW.previous_id;
|
||||
IF NOT FOUND OR ROW(r.tenant_id,r.company_id,r.kind,r.entity_key,r.version+1) IS DISTINCT FROM ROW(NEW.tenant_id,NEW.company_id,NEW.kind,NEW.entity_key,NEW.version) THEN RAISE EXCEPTION 'SGF_CA_PREVIOUS_SCOPE'; END IF;
|
||||
END IF;
|
||||
IF NEW.replaces_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ca_proposals p JOIN ca_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.replaces_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.kind=NEW.kind AND p.entity_key=NEW.entity_key AND p.valid_from<=NEW.valid_from) THEN RAISE EXCEPTION 'SGF_CA_REPLACEMENT_SCOPE'; END IF;
|
||||
keys:=CASE NEW.kind WHEN 'plan' THEN ARRAY['plan','source_version'] WHEN 'account' THEN ARRAY['plan_id','code','name','origin','movable','evidence'] WHEN 'category' THEN ARRAY['name','account_id','conditions'] WHEN 'service_link' THEN ARRAY['service_id','category_id'] ELSE ARRAY['service_id','account_id','conditions'] END;
|
||||
IF jsonb_typeof(NEW.data) IS DISTINCT FROM 'object' OR NEW.data-keys<>'{}'::jsonb THEN RAISE EXCEPTION 'SGF_CA_UNSUPPORTED_DATA'; END IF;
|
||||
FOREACH ref IN ARRAY ARRAY['plan_id','account_id','category_id'] LOOP
|
||||
IF NEW.data ? ref AND NOT EXISTS(SELECT 1 FROM ca_proposals p WHERE p.id=(NEW.data->>ref)::bigint AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.kind=CASE ref WHEN 'plan_id' THEN 'plan' WHEN 'account_id' THEN 'account' ELSE 'category' END) THEN RAISE EXCEPTION 'SGF_CA_REFERENCE_SCOPE'; END IF;
|
||||
END LOOP;
|
||||
IF NEW.data ? 'service_id' AND NOT EXISTS(SELECT 1 FROM a_services s WHERE s.id=(NEW.data->>'service_id')::bigint AND s.tenant_id=NEW.tenant_id AND s.company_id=NEW.company_id) THEN RAISE EXCEPTION 'SGF_CA_SERVICE_SCOPE'; END IF;
|
||||
IF NEW.kind IN ('service_link','service_override') AND NEW.entity_key<>'service:'||(NEW.data->>'service_id') THEN RAISE EXCEPTION 'SGF_CA_SERVICE_KEY'; END IF;
|
||||
IF NEW.kind='plan' AND (NEW.entity_key<>'PGC-NIRF' OR NEW.data->>'plan'<>'PGC-NIRF') THEN RAISE EXCEPTION 'SGF_CA_PLAN'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ca_proposal_insert BEFORE INSERT ON ca_proposals FOR EACH ROW EXECUTE FUNCTION sgf_ca_proposal_guard();
|
||||
CREATE OR REPLACE FUNCTION sgf_ca_event_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE p record;
|
||||
BEGIN
|
||||
SELECT * INTO p FROM ca_proposals WHERE id=NEW.proposal_id AND tenant_id=NEW.tenant_id AND company_id=NEW.company_id;
|
||||
IF NOT FOUND OR EXISTS(SELECT 1 FROM ca_proposals n WHERE n.company_id=p.company_id AND n.kind=p.kind AND n.entity_key=p.entity_key AND n.version>p.version) THEN RAISE EXCEPTION 'SGF_CA_EVENT_VERSION'; END IF;
|
||||
IF TG_TABLE_NAME='ca_decisions' AND NOT EXISTS(SELECT 1 FROM ca_submissions WHERE proposal_id=p.id) THEN RAISE EXCEPTION 'SGF_CA_NOT_SUBMITTED'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ca_submission_insert BEFORE INSERT ON ca_submissions FOR EACH ROW EXECUTE FUNCTION sgf_ca_event_guard();
|
||||
CREATE TRIGGER ca_decision_insert BEFORE INSERT ON ca_decisions FOR EACH ROW EXECUTE FUNCTION sgf_ca_event_guard();
|
||||
CREATE OR REPLACE FUNCTION sgf_ca_resolution_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.area='service_drafts' THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM a_service_drafts r WHERE r.id=NEW.record_id AND r.tenant_id=NEW.tenant_id AND r.company_id=NEW.company_id AND r.unit_id=NEW.unit_id AND r.version=NEW.record_version AND EXISTS(SELECT 1 FROM jsonb_array_elements(r.data->'lines') l WHERE (l->>'service_id')::bigint=NEW.service_id)) THEN RAISE EXCEPTION 'SGF_CA_OPERATION_SCOPE'; END IF;
|
||||
ELSIF NEW.area='billing_drafts' THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM a_billing_drafts r WHERE r.id=NEW.record_id AND r.tenant_id=NEW.tenant_id AND r.company_id=NEW.company_id AND r.unit_id=NEW.unit_id AND r.version=NEW.record_version AND EXISTS(SELECT 1 FROM jsonb_array_elements(r.data->'lines') l WHERE (l->>'service_id')::bigint=NEW.service_id)) THEN RAISE EXCEPTION 'SGF_CA_OPERATION_SCOPE'; END IF;
|
||||
ELSE RAISE EXCEPTION 'SGF_CA_OPERATION_KIND'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ca_resolution_insert BEFORE INSERT ON ca_resolutions FOR EACH ROW EXECUTE FUNCTION sgf_ca_resolution_guard();
|
||||
SQL);
|
||||
foreach (['ca_proposals','ca_submissions','ca_decisions','ca_resolutions','ca_commands'] as $table) { DB::statement("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON $table FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()"); }
|
||||
$company=['company.view','company.edit','unit.create','access.manage','issuer_documents.view','issuer_documents.edit','client_documents.view','client_documents.edit','document_preview.configure','catalogue_accounting.view','catalogue_accounting.propose','catalogue_accounting.approve'];
|
||||
$unit=['unit.view','unit.edit','billing_drafts.confirm_internal','draft_documents.view','draft_documents.edit'];
|
||||
foreach (['clients','services','service_drafts','billing_drafts'] as $area) { foreach (['view','create','edit','manage'] as $action) { if (in_array($area,['clients','services'],true)) { $company[]="$area.$action"; } else { $unit[]="$area.$action"; } } }
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('".implode("','",$company)."') AND unit_id IS NULL) OR (permission IN ('".implode("','",$unit)."') AND unit_id IS NOT NULL))");
|
||||
}
|
||||
public function down(): void { throw new RuntimeException('Accounting classification history requires a preservation procedure.'); }
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
# Facturação e recebimentos — matriz de informação para revisão
|
||||
|
||||
Especificação posterior de 26 de Outubro de 2026: [primeiro cálculo de factura de serviço](SERVICE_INVOICE_CALCULATION_SPEC.md), com caso mínimo, fórmulas, precisão, arredondamento/reconciliação, fontes oficiais parcialmente verificadas e lacunas explícitas. Apenas proposta, sem activação de taxa ou autorização de emissão.
|
||||
|
||||
Plano posterior de 26 de Outubro de 2026: [pré-visualização de rascunho de factura de serviço](DRAFT_INVOICE_PREVIEW_PLAN.md), com apresentação provisória, critérios, decisões e fontes ainda necessárias. Apenas proposta; preserva a preparação entregue e as bases aceites, sem autorização de implementação ou emissão.
|
||||
|
||||
Data: 26 de Outubro de 2026 (Africa/Johannesburg).
|
||||
|
||||
Estado: especificação apenas. O utilizador indicou que os três exemplos representam informação a contemplar nas facturas e recibos. Isso define uma referência de conteúdo pretendido, não a obrigatoriedade universal de todos os campos nem a validade fiscal dos modelos. Não autoriza implementação, emissão, recebimentos, contabilização, alterações de permissões ou publicação.
|
||||
|
||||
Actualização posterior: o utilizador autorizou um incremento delimitado de preparação documental, incluindo dados de emitente/clientes, preparação dos rascunhos e instruções de pagamento do emitente reutilizáveis. Banco/titular/identificadores/moeda/instruções facilitam o pagamento; não perguntam como o cliente vai pagar e não criam recebimento. Foram autorizados por prompt de execução próprio, com confirmação específica das novas concessões. A execução está documentada em [DOCUMENT_PREPARATION_DELIVERY.md](DOCUMENT_PREPARATION_DELIVERY.md). As restantes propostas desta matriz continuam sujeitas a validação e autorização, sem emissão fiscal, recebimento ou contabilização.
|
||||
|
||||
Preservar B1 aceite e o checkpoint `8fcd52c`, A (`ca13acb`) e identidade (`26b80df`). As fotografias B1 mantêm o significado «Confirmado internamente — sem validade fiscal». Esta revisão não lhes acrescenta validade, dados ou efeitos retrospectivos.
|
||||
|
||||
## Fontes e grau de validação
|
||||
|
||||
| Referência | Utilização nesta revisão | Limite |
|
||||
| --- | --- | --- |
|
||||
| SGF_TEMPO_Roteiro_v0_9.docx; SERVICES_WORKFLOW_SPEC.md; SERVICES_B_SCOPE.md | Reutilização de dados, distinção entre acontecimentos, captura permanente e pendências | Requisitos de produto e propostas; não são legislação fiscal |
|
||||
| SERVICES_A_DELIVERY.md e SERVICES_B1_DELIVERY.md, entregas aceites | Fundação existente: cadastro mínimo, rascunhos, decimais exactos e fotografia interna | Não incluem emissão fiscal ou recebimento |
|
||||
| E1 — Factura.jpg | Na imagem é um **recibo Tempo**, n.º 10, série 2026; linha refere **FT 10/2026** | O nome do ficheiro não determina o tipo documental |
|
||||
| E2 — Factura.png | **Factura-recibo TVCABO**, n.º 219144 / FR26; detalhe de serviço e informação de pagamento | Exemplo de conteúdo de outro emitente; não define regras para a Tempo |
|
||||
| E3 — Sem título.jpg | **Factura Tempo FT 11/2026**, com linhas, imposto e condições de pagamento | Não é a factura referenciada pelo recibo E1 |
|
||||
| Esclarecimento do utilizador nesta sessão | Os exemplos mostram informação que deve ser contemplada em facturas e recibos | Campos aplicáveis por documento/regime ainda carecem de delimitação |
|
||||
|
||||
Os exemplos foram lidos visualmente. Não foram copiados para o repositório nem transformados em operações/fixtures. Não transcrever números de contas, referências de pagamento ou assinaturas reais como configuração ou valores por omissão. Texto ambíguo numa imagem deve ser conferido no original, nunca completado por suposição.
|
||||
|
||||
**Fontes legais já validadas nesta tarefa: nenhuma.** Não foram fornecidos ou validados diplomas, orientações oficiais ou pareceres aplicáveis ao regime, período e operações da empresa. Esta matriz propõe informação e validações técnicas; antes de chamar um campo obrigatório por lei, associar-lhe fonte oficial, artigo, vigência, aplicabilidade e validação do responsável competente. A taxa observada nos exemplos não constitui validação da taxa aplicável.
|
||||
|
||||
## Tipos documentais e acontecimentos
|
||||
|
||||
| Tipo | Significado funcional proposto | Ligações necessárias | Não inferir |
|
||||
| --- | --- | --- | --- |
|
||||
| Factura | Documenta a facturação de bens/serviços segundo requisitos a validar | Empresa/unidade, cliente, linhas, origens e versão; recebimentos por afectação posterior | Que o serviço foi executado, que houve recebimento ou que ocorreu reconhecimento contabilístico |
|
||||
| Recibo | Documenta um recebimento identificado e as suas afectações, conforme âmbito a validar | Identidade própria, recebimento(s), documento(s) abrangidos e montante de cada afectação | Que o número do recibo é o da factura; que montante igual identifica a factura; que a emissão confirma liquidação bancária |
|
||||
| Factura-recibo | Representa conjuntamente facturação e recebimento no caso expressamente admitido e aprovado | Identidades dos dois acontecimentos e afectações explícitas, mesmo com uma apresentação conjunta | Que qualquer factura se torna paga ao ser emitida, ou que se pode contar novamente o recebimento num recibo separado |
|
||||
|
||||
Admissibilidade, momento de emissão e condições de utilização de cada tipo são pendências legais/operacionais. Uma factura-recibo não elimina a separação interna entre documento, recebimento, afectação e eventual lançamento contabilístico.
|
||||
|
||||
## O que os exemplos mostram
|
||||
|
||||
- E1: recibo emitido em 15/10/2026, com campo de vencimento também em 15/10/2026; a linha refere FT, série 2026, n.º 10, emitida em 09/08/2026 e com vencimento em 10/08/2026. Mostra valor e valor pago de 6 744,24, pendente 0,00 e total em MZN. As datas do cabeçalho são distintas das datas da factura referida. Não adoptar um vencimento de recibo por ser um campo existente no modelo.
|
||||
- E3: FT 11/2026, emitida em 12/10/2026, vencimento em 12/11/2026; condição impressa «Pagamento em 30 dias». Preservar ambos os dados observados e sinalizar a compatibilidade para revisão; não deduzir uma convenção de contagem nem corrigir silenciosamente a data. Descrição «Aluguer de Servidor Novembro», quantidade 1, unidade UN, preço/base 5 814,00, imposto apresentado 930,24 e total 6 744,24 MZN. O mês na descrição não fornece, por si só, um período exacto de serviço ou uma data de reconhecimento.
|
||||
- E2: preço apresentado 2 380,00, IVA identificado como «16 % Inc.», base apresentada 2 051,72, imposto 328,28 e total 2 380,00. O período de serviço indicado é 25/10/2026–23/11/2026; a emissão é 15/10/2026 às 10:50. Traz contrato, pedido de adesão, morada de instalação, indicação de pagamento VISA e extracto de conta. Estes campos têm funções diferentes e não devem tornar-se requisitos universais.
|
||||
- E1 e E3 têm o mesmo montante, mas identidades diferentes: **o recibo E1 não liquida FT 11/2026**. FT 10/2026 não foi fornecida como documento autónomo; apenas a sua referência aparece no recibo. A associação de E1 a FT 10/2026 fica registada como referência observada, sem inventar o conteúdo da factura ausente.
|
||||
- «Original», «Duplicado», carimbos, assinaturas e anotações manuscritas são elementos observados de apresentação/evidência. Não provam, por si só, validade legal, autoria electrónica ou data efectiva do recebimento. Uma anotação de entrega/recepção do papel não substitui a data do recebimento financeiro.
|
||||
|
||||
## Convenções da matriz
|
||||
|
||||
**Base a validar** significa candidato a informação documental, não obrigação legal já aprovada. «Exigir» abaixo refere-se à proposta funcional no momento indicado, sujeita a fechar as decisões. FT = factura; RC = recibo; FR = factura-recibo. «Fotografia: sim» significa conservar o valor utilizado no acontecimento futuro, com origem/versão, sem alterar as fotografias B1 já existentes. Informação desconhecida fica pendente; não vira vazio validado, zero ou valor por omissão.
|
||||
|
||||
### 1. Base de facturação a validar legalmente
|
||||
|
||||
| Campo | Origem / reutilização | Momento em que se propõe exigir | Validação proposta / pendência | Fotografia histórica |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Identidade técnica do documento e tipo | Servidor; catálogo de tipos suportados | Criação do registo documental | ID estável; tipo permitido e âmbito explícito; referência interna B1 nunca vira número fiscal | Sim, identidade e tipo |
|
||||
| Tenant, empresa emitente e unidade | Contexto e cadastros existentes | Desde o rascunho | Contexto autorizado, unidade da empresa; sem transferência por formulário ou SQL em massa | Sim, IDs e identificação usada |
|
||||
| Denominação do emitente | Cadastro da empresa | Preparação documental; completude antes de eventual emissão | Reutilizar nome; distinguir designação interna de denominação legal a validar | Sim, nome utilizado e versão |
|
||||
| Identificação fiscal do emitente (NUIT) | Cadastro futuro da empresa | Antes de eventual emissão, se aplicável | Formato, validade e obrigação dependem do regime/fonte; não inferir do nome ou de exemplos | Sim, se utilizada |
|
||||
| Morada/localidade/país do emitente | Cadastro da empresa/unidade, conforme papel aprovado | Antes da emissão quando aplicável | Distinguir sede, estabelecimento e local da operação; conteúdo exigível por validar | Sim, dados efectivamente usados |
|
||||
| Identidade do cliente e nome/denominação | Cadastro de cliente existente | Nome basta para iniciar rascunho; identidade estável desde a associação | Não duplicar por unidade; cadastro mínimo não significa aptidão para emissão | Sim, ID, nome e versão |
|
||||
| NUIT do cliente / identificação alternativa admitida | Cadastro de contraparte, a especificar | Antes da emissão nos casos aplicáveis | Condições por tipo de cliente/operação, formato e alternativas por validar; não exigir por suposição em todo rascunho | Sim, valor e tipo usados |
|
||||
| Morada/localidade/país do cliente | Cadastro da contraparte | Antes da emissão quando aplicável | Não confundir morada fiscal, correspondência e instalação; requisitos por validar | Sim, papel e valor usados |
|
||||
| Série, número e identificação legível | Mecanismo documental futuro, por empresa/tipo e regras aprovadas | No momento de atribuição legal a definir | Unicidade, sequência, exercício/série, concorrência, lacunas e momento de atribuição pendentes; não copiar FT/FR26 como convenção universal | Sim; separado do ID técnico |
|
||||
| Data/hora de emissão | Operação documental futura | Emissão; não preencher como emissão real no rascunho | Fuso, data admissível e retroactividade por validar; não usar instante B1 como emissão | Sim, data e referência temporal |
|
||||
| Origem: prestação/rascunho/documento e versão | Relações existentes de A/B1 | Preparação e fixação documental | Mesma empresa/unidade; versão actual, origem válida; múltiplas/parciais dependem de decisão | Sim, IDs e versões |
|
||||
| Serviço/artigo e código utilizado | Catálogo empresarial existente | Escolha da linha; completude documental | Reutilizar serviço; código do exemplo não é chave universal; não criar stock por haver coluna «Artigo» | Sim, ID, código se usado, nome e versão |
|
||||
| Descrição da linha | Catálogo, prestação e eventual complemento autorizado | Preparação; completa antes de confirmação/emissão correspondente | Descrição inteligível; preservar complemento sem reescrever cadastro partilhado | Sim |
|
||||
| Quantidade | Prestação/rascunho | Pode ficar pendente em A; completa na confirmação B1 e preparação documental completa | Decimais exactos; sem coerção/arredondamento; regras para casos negativos/correcções fora deste âmbito | Sim |
|
||||
| Unidade de medida | Referência parametrizável futura do serviço/linha | Quando necessária à interpretação/emissão da linha | Não presumir UN; vocabulário e obrigatoriedade por operação a validar | Sim, código e significado usados |
|
||||
| Preço unitário e moeda do preço | Rascunho/acordo de serviço | Preparação dos valores; emissão exige definição aplicável | Precisão de A preservada; moeda desconhecida não é MZN; não apresentar valor definitivo sem moeda definida | Sim |
|
||||
| Modo de preço: imposto incluído ou acrescido | Escolha/regra aprovada e versionada | Antes de apurar base/imposto/total para emissão | Explícito; não inferir do montante ou do formato da impressão; A/B1 ainda não definem esse modo | Sim, modo e regra |
|
||||
| Moeda documental | Regra/indicação aprovada para o documento | Pode ser desconhecida em A/B1; definir antes de emissão monetária | Código e aplicabilidade; não inferir moeda funcional, câmbio ou política fiscal | Sim; desconhecimento B1 permanece preservado |
|
||||
| Base tributável por linha e por tratamento | Cálculo futuro segundo regras aprovadas | Apuramento documental | Separar quantidade × preço, descontos, base e incidência; base aritmética de A não é automaticamente base tributável | Sim, componentes e regra |
|
||||
| Tratamento fiscal, taxa e fundamento | Regra fiscal versionada, validada para empresa/operação/data | Apuramento/emissão quando aplicável | Distinguir tributado, isento, não sujeito ou outro tratamento aprovado; desconhecido nunca equivale a taxa zero | Sim, classificação, taxa/fundamento e versão |
|
||||
| Montante de imposto e resumo por tratamento | Cálculo futuro | Apuramento/emissão | Escala, modo e ponto de arredondamento a validar; reconciliar linhas/resumo; não somar moedas distintas | Sim, cálculo e resultados aplicados |
|
||||
| Total documental | Cálculo das componentes aprovadas | Antes de emissão | Base + imposto e demais componentes apenas conforme regra; explicável pelas linhas; não converter B1 em total fiscal | Sim |
|
||||
| Autor, instante e versões de dados/regras | Sessão e servidor; referências versionadas | Em cada acto relevante | Autor real e autorização; nenhum privilégio associado ao rótulo impresso «admin» | Sim; sem tokens ou segredos |
|
||||
|
||||
### 2. Requisitos condicionais por documento/operação
|
||||
|
||||
| Campo / condição | Origem | Momento em que se propõe exigir | Validação proposta / pendência | Fotografia histórica |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Vencimento (FT; outros tipos só se houver significado aprovado) | Condições acordadas + data, ou indicação autorizada | Preparação de cobrança/emissão quando aplicável | Distinto da emissão; convenção de dias, excepções e alterações a aprovar. Campo de vencimento do RC E1 não o torna universal | Sim, vencimento original; mudanças futuras rastreáveis |
|
||||
| Condições/prazo de pagamento (FT) | Acordo/parametrização empresarial versionada | Preparação documental | Coerência com vencimento; não impor «30 dias»; explicitar divergências, sem corrigir silenciosamente | Sim |
|
||||
| Data/período da prestação | Prestação/acordo de serviço | Quando aplicável ao serviço/documento | Separar início/fim, execução, emissão e competência; não extrair período exacto da palavra «Novembro» | Sim, quando utilizado |
|
||||
| Desconto comercial/financeiro, em taxa e/ou valor | Acordo e regra aplicável | Se existir desconto | Explicitar por linha/documento, ordem, base e efeito no imposto; zeros dos exemplos não aprovam regra | Sim, componentes e regra |
|
||||
| Motivo/código de isenção ou outro tratamento | Classificação fiscal validada | Quando esse tratamento for utilizado | Fonte, condições, prova e texto exigível por validar; ausência de imposto não prova isenção | Sim, fundamento e versão |
|
||||
| Adiantamentos, outras contribuições, encargos/outros serviços | Operação de origem e classificação futura | Apenas se ocorrerem e forem incluídos | Não usar campos genéricos para ajustar total; identidade, sinal, moeda e tratamento próprios; evitar dupla contagem | Sim, se integrarem o documento |
|
||||
| Identidade do recebimento (RC/FR) | Registo futuro de recebimento, distinto do documento | Antes de documentar um recebimento | Não iniciar transferência; distinguir registo, evidência e verificação; novas regras/permissões por aprovar | Sim, ligação e estado observado |
|
||||
| Data/hora do recebimento (RC/FR) | Acontecimento e evidência de recebimento | Registo/documentação do recebimento | Separar da emissão do recibo, data bancária, registo no sistema e anotações manuscritas | Sim, datas e proveniência |
|
||||
| Montante recebido e moeda (RC/FR) | Recebimento identificado | Antes de produzir comprovativo | Positividade/precisão, moeda e evidência conforme regras a aprovar; montante declarado não comprova liquidação externa | Sim |
|
||||
| Meio de recebimento (RC/FR) | Registo/evidência; catálogo a delimitar | Quando documentado o recebimento e conforme operação | VISA é um exemplo, não fornecedor escolhido; não guardar números completos de cartão ou credenciais | Sim, classificação e referência não secreta necessária |
|
||||
| Referência/evidência da transacção (RC/FR) | Fonte observada/documento comprovativo | Conforme meio e critérios de verificação aprovados | Identificador contextual, origem, acesso e duplicados; QR/referência para pagar não prova que houve pagamento | Sim, ID da evidência/estado; acesso ao original protegido |
|
||||
| Documentos afectados: tipo, identidade, série e número (RC/FR) | Referência explícita e documento de origem | Antes de confirmar a afectação | Empresa, contraparte, moeda e documento exactos; FT 10/2026 não é FT 11/2026 | Sim, identidade e referência utilizada |
|
||||
| Valor de cada afectação (RC/FR) | Relação recebimento ↔ documento | Ao distribuir o recebimento | Uma identidade por afectação; controlar montante disponível, saldo elegível e repetição; casos de excedentes/câmbio dependem de regra | Sim, montante, moeda e origem |
|
||||
| Valor do documento, pago e pendente à data (RC/FR ou extracto) | Documento e afectações elegíveis no instante | Apresentação do comprovativo/extracto | Não editar livremente; distinguir afectação actual de acumulado; preservar data de corte e não confundir com saldo actual | Sim, se apresentados; valores actuais em consulta separada |
|
||||
| Conteúdo conjunto de facturação e recebimento (FR) | Documento + recebimento + afectação | Só no caso de uso FR aprovado | Completude de ambos, identidade/correlação, atomicidade e repetição; não contar outra dívida ou outro recebimento pela mesma operação | Sim, ambos e suas relações |
|
||||
| Original/duplicado, exemplar/página e reimpressão | Política de apresentação/reprodução a aprovar | Representação documental futura | Uma reprodução não cria outra emissão/recebimento; etiquetas e exigências legais por validar | Documento e metadados de reprodução, se aplicáveis |
|
||||
| Identificação do software/textos legais/certificação | Configuração e requisitos oficialmente validados | Se exigíveis na representação/emissão | Não copiar «processado por computador» ou licença de outro sistema como prova de conformidade | Sim, texto/versão aplicados, se usados |
|
||||
|
||||
### 3. Informação adicional parametrizável para gestão/estatísticas
|
||||
|
||||
Esta classificação é provisória: um campo pode mudar para condicional se um contrato, actividade ou fonte legal validada o exigir. Não construir agora um motor geral de parâmetros nem tornar todas estas colunas obrigatórias.
|
||||
|
||||
| Campo | Origem | Quando exigir | Validação proposta | Fotografia histórica |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Número interno do cliente | Identidade/código do cadastro na empresa | Se usado em pesquisa ou apresentação | Unicidade no âmbito definido; não substituir ID técnico, NUIT ou associação documental | Sim, se impresso/utilizado |
|
||||
| Contrato e pedido de adesão/encomenda | Referências empresariais futuras | Só para operações que os utilizem | Mesma empresa/contraparte; referência externa com emissor; não implementar contratos recorrentes por causa de E2 | Sim, ligação e referência usadas |
|
||||
| Morada/local de instalação ou prestação | Cliente/contrato/local da operação | Serviços que precisem dessa distinção | Separar de morada fiscal e de unidade interna; não duplicar o cliente | Sim, se relevante/mostrado |
|
||||
| Renovação/vigência do contrato/serviço | Acordo e acontecimento próprio | Se houver serviço com vigência | Não derivar automaticamente de emissão ou pagamento sem regra; estado de renovação distinto do recebimento | Sim, informação apresentada |
|
||||
| Loja/local de atendimento, vendedor e operador | Contexto/identidades e actividade comercial | Conforme necessidade de análise | Loja externa de E2 não corresponde automaticamente à unidade SEGI; operador não significa administrador | Sim, ID/nome usado quando documentado |
|
||||
| Telefone, email, website e apoio ao cliente | Cadastros/configuração empresarial | Se escolhidos para comunicação/apresentação | Não exigir como dados fiscais por suposição; não copiar contactos de exemplos | Sim, se fizerem parte do documento fixado |
|
||||
| Logótipo e elementos visuais | Identidade visual configurada | Se usados na representação | Sem alterar significado/tipo; distinguir marca e denominação legal | Versão/activo utilizado, se renderizado |
|
||||
| Capital social e outros dados institucionais | Cadastro validado do emitente | Se aplicáveis e escolhidos/exigidos | Obrigatoriedade e actualidade por validar; não usar valor de E2 como configuração | Sim, se apresentados |
|
||||
| Dados bancários, entidade/referência e instruções de pagamento | Configuração própria autorizada | Quando forem apresentadas instruções | Titularidade, moeda, finalidade e controlo de acesso; não importar contas/referências dos exemplos nem pedir credenciais | Sim, dados comunicados naquele documento; segredos excluídos |
|
||||
| Valor por extenso | Derivado do valor e moeda apresentados | Se adoptado ou exigido após validação | Concordância com montante, moeda, língua e precisão; não é valor independente editável | Sim, se apresentado |
|
||||
| Observações | Registo autorizado ligado à operação | Se necessário | Não substituir campos estruturados, fundamento fiscal ou decisão; limitar informação pessoal | Sim, texto fixado no documento |
|
||||
| Extracto: documentos, movimentos, saldo anterior/actual | Consulta derivada de origens e afectações | Se for incluído como informação adicional | Data de corte, sinais, moeda e estados; linhas positivas/negativas de E2 não são prova do plano contabilístico nem novos recebimentos | Sim, se incorporado na representação; não substituir origens |
|
||||
| Carga/descarga, local e hora | Operação logística, se existir | Só actividade com necessidade identificada | Não impor a serviços por aparecer no template E3; não presumir entrega efectiva | Sim, se utilizados |
|
||||
| Assinatura, carimbo, recepção do documento e anexos | Evidência específica, não sessão de login | Se existir procedimento aprovado | Distinguir recepção do papel, autorização, recebimento financeiro e assinatura válida; retenção/acesso por aprovar | Referência e metadados da evidência; original protegido |
|
||||
| QR, portal do cliente, publicidade e rodapé comercial | Configuração de apresentação | Opcional, se autorizado | QR pode ser ligação/instrução, não prova de pagamento; nenhum fornecedor/portal escolhido nesta revisão | Versão do conteúdo/activo, se apresentado |
|
||||
| Etiquetas analíticas (actividade, centro de custo, projecto, canal) | Referências parametrizadas futuras | Só se houver finalidade de gestão aprovada | Vocabulário e âmbito definidos; não inferir contas nem construir dimensões sem necessidade | Sim se usadas para explicar/classificar a operação; alterações futuras versionadas |
|
||||
|
||||
## Preço, imposto e total: significado a explicitar
|
||||
|
||||
O preço de A/B1 é um preço proposto sem modo fiscal aprovado. Esta revisão não o converte retroactivamente em preço com imposto incluído ou acrescido.
|
||||
|
||||
- **Acrescido:** num exemplo artificial com uma só taxa, sem descontos/encargos, base = quantidade × preço sem imposto; imposto = base × taxa; total = base + imposto, sujeito à política de cálculo ainda por validar. E3 mostra 5 814,00 + 930,24 = 6 744,24, compatível aritmeticamente com 16% acrescido. Isso não determina o regime/taxa aplicáveis a outra operação.
|
||||
- **Incluído:** no mesmo caso simplificado, preço bruto = base + imposto; para taxa r, base exacta = bruto ÷ (1 + r), antes da política de arredondamento aprovada. E2 apresenta 2 051,72 + 328,28 = 2 380,00 e explicita imposto incluído. A divisão exacta de 2 380 por 1,16 tem mais casas que as impressas; a imagem não revela o algoritmo, ponto ou modo de arredondamento.
|
||||
- Descontos, múltiplos tratamentos, encargos, adiantamentos, câmbio e eventuais retenções precisam de regras próprias antes de integrar os cálculos. Não inferir a sua aplicabilidade, sinal ou incidência da presença de um campo num modelo.
|
||||
- Guardar valores de entrada, modo, resultados e versões das regras aplicadas. Escala de armazenamento, precisão de cálculo, arredondamento por linha/documento e apresentação são decisões diferentes. Não inferir «duas casas em todos os cálculos» das duas casas impressas.
|
||||
- Nunca somar montantes de moedas distintas nem apresentar uma soma de preços sem moeda como total monetário definitivo. Diferenças de arredondamento devem ser explicáveis segundo regra aprovada, sem ajuste silencioso.
|
||||
|
||||
## Identidade, recebimento e afectação
|
||||
|
||||
Modelo conceptual proposto, sem migração ou implementação nesta etapa:
|
||||
|
||||
1. Cada documento tem ID técnico estável e referência legível contextualizada por empresa, tipo, série e número. A série não é presumida como ano em todos os sistemas. A apresentação de E2 não determina como separar a série FR26 de outros identificadores.
|
||||
2. O recebimento tem identidade própria, moeda, montante, datas, origem/evidência e estado. Um RC/FR liga-se ao acontecimento efectivamente documentado; não cria outro recebimento ao ser impresso, repetido ou importado.
|
||||
3. A afectação tem ID próprio e liga recebimento a documento exacto com montante/moeda. Suportar conceitualmente pagamentos parciais e distribuição por documentos sem duplicação; a cardinalidade legal/operacional de recibos, recebimentos e adiantamentos deve ser aprovada antes de implementar.
|
||||
4. Controlar, conforme política aprovada, soma das afectações por recebimento e saldo elegível do documento, concorrência e idempotência. Repetição de importação/referência não identifica automaticamente a operação sem fonte e âmbito. Excedentes, diferenças cambiais e correcções permanecem pendentes.
|
||||
5. Documento com referência externa ainda não resolvida fica pendente. Em E1, conservar «FT / 2026 / 10» e procurar apenas essa identidade no âmbito certo. Sem FT 10/2026 disponível, não associar FT 11/2026 nem criar uma factura completa fictícia.
|
||||
6. Em FR, ligar explicitamente o componente documental, o recebimento e sua afectação. Se for aprovado um caso de recebimento integral imediato, garantir atomicidade e repetição segura sem duplicar efeitos num recibo separado. A imagem, por si só, não aprova o caso de uso nem comprova reconciliação externa.
|
||||
7. Saldo operacional, evidência de recebimento, emissão fiscal e contabilização são estados distintos. Extracto ou total pendente zero não demonstra reconciliação bancária ou conformidade contabilística.
|
||||
|
||||
## Decisões pendentes
|
||||
|
||||
| Decisão | O que é necessário fechar |
|
||||
| --- | --- |
|
||||
| Fontes legais e responsável pela validação | Regime da empresa, tipos documentais admitidos, operações/períodos abrangidos, fontes oficiais com vigência/artigos e validação fiscal competente |
|
||||
| Campos obrigatórios por tipo/caso | Denominação, NUIT, moradas, identificação de cliente particular/empresa, dados do serviço, textos e eventuais excepções; separar mínimo de rascunho e aptidão para emissão |
|
||||
| Identidade documental e representação | Séries/numeração, atribuição, sequência/lacunas, data/hora, exemplares, duplicados, software/certificação, impressão e arquivo quando aplicáveis |
|
||||
| Preços e descontos | Modo incluído/acrescido por âmbito aprovado, descontos, encargos, incidência, taxas/fundamentos, moeda e arredondamento; não adoptar valores dos exemplos como regra |
|
||||
| Datas e condições | Emissão, vencimento, prazo/convenção de dias, período do serviço e datas de recebimento; tratamento da divergência entre prazo e data sem alteração silenciosa |
|
||||
| Recibo e factura-recibo | Condições de utilização, data, meios/evidências, referência da factura, recebimentos parciais/múltiplos, adiantamentos, excesso e prevenção de dupla documentação |
|
||||
| Gestão e estatísticas | Quais campos adicionais devem existir no primeiro caso de uso, quais aparecem no documento e quais ficam só em consulta; evitar cadastro obrigatório excessivo |
|
||||
| Fotografia e protecção de dados | Conteúdo fixado, versões, retenção/acesso às evidências, elementos visuais e tratamento posterior sem alterar origem; nenhum segredo no histórico |
|
||||
| Permissões futuras | Quem prepara/verifica dados, emite, regista recebimento, afecta ou gere esses poderes; direitos de A/B1 não os concedem; aplicações exigem confirmação própria |
|
||||
| Contabilidade | Plano de contas, reconhecimento, moeda funcional, câmbio, datas/períodos e regras de processamento continuam pendentes; não são resolvidos pelo layout dos exemplos |
|
||||
|
||||
## Próximo incremento pequeno proposto — preparação e verificação documental
|
||||
|
||||
**Proposta para aprovação, sem execução autorizada:** enriquecer apenas os cadastros e a preparação do rascunho com o subconjunto de campos que vier a ser aprovado, e mostrar uma verificação de completude documental separada da confirmação interna B1.
|
||||
|
||||
Antes do prompt de implementação, aprovar: (a) primeiro tipo/caso de uso, propondo FT de serviço pontual; (b) lista exacta de campos e origem, incluindo denominação/morada/identificação fiscal nos casos validados; (c) datas/período/condições necessários; (d) como registar o modo de preço proposto e as pendências de moeda/imposto, sem motor fiscal; (e) permissões de edição desses novos dados, sem herança presumida. Recibos e FR ficam especificados, mas não implementados nesse pequeno incremento.
|
||||
|
||||
Resultado verificável proposto: reutilizar o cliente criado só com nome, completar os dados aprovados uma vez, seleccionar esse cliente num novo rascunho e mostrar «dados por completar», «dados preenchidos, validação pendente» ou outro rótulo aprovado. **Nenhum estado deve dizer «apto para emissão fiscal» enquanto faltarem regras/fontes validadas.** Não emitir, numerar fiscalmente, produzir PDF de factura, receber, afectar valores ou contabilizar.
|
||||
|
||||
Manter os documentos B1 existentes imutáveis; enriquecimento posterior dos cadastros não lhes acrescenta dados. Se for necessária uma fotografia mais rica em operações futuras, especificar nova versão do esquema, compatibilidade e migração aditiva antes de implementar. Não reconstruir ou promover confirmações B1 aceites a facturas.
|
||||
|
||||
Provas futuras desse incremento: reutilização sem duplicação de clientes; campos condicionais sem bloquear o início do rascunho; desconhecido distinto de zero/preenchido; datas distintas; acesso cruzado recusado; fotografia B1 inalterada; nenhuma emissão, novo recebimento ou lançamento. Só preparar o prompt executável depois de aprovado este subconjunto e os direitos correspondentes.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Classificação contabilística do catálogo — proposta do próximo incremento
|
||||
|
||||
**Actualização posterior:** o utilizador autorizou explicitamente a execução do primeiro incremento com as três salvaguardas. A implementação, provas e limites estão em [CATALOGUE_ACCOUNTING_DELIVERY.md](CATALOGUE_ACCOUNTING_DELIVERY.md). As quatro concessões iniciais foram posteriormente confirmadas de forma específica e aplicadas, conforme o relatório. O texto original de proposta abaixo preserva a origem das decisões; não revoga essa autorização posterior nem significa activação de contas/regras reais. A validação do cálculo permanece separada e pendente.
|
||||
|
||||
26 de Outubro de 2026, Africa/Johannesburg. **Apenas especificação para aprovação.** Não escolhe o plano da Tempo Lda, aplica acessos, altera bases, produz lançamentos ou autoriza implementação. A validação do incremento de cálculo continua pendente; esta proposta não o dá por aceite nem altera a sua política. Preservar identidade, A, B1, preparação documental, pré-visualização e histórico.
|
||||
|
||||
## 1. Documento-base e alcance da leitura
|
||||
|
||||
Exemplar fornecido pelo utilizador: `\\Mac\Home\Downloads\29-Plano-de-Contas-BR-Decreto-70-2009-de-22-de-Dezembro.pdf`. Decreto n.º 70/2009, de 22 de Dezembro, BR, I Série, n.º 50, 4.º suplemento; 192 páginas PDF, 856306 bytes. SHA-256: `45C38AED64DE43042ED048586ACCE2EC660B74FC3F8FBCE33D39AAB45C7529E3`.
|
||||
|
||||
Leitura do exemplar histórico fornecido, com extracção textual e conferência visual das páginas PDF 127, 134, 178 e 192, especialmente porque a extracção das colunas desloca códigos e descrições. As referências abaixo usam página PDF contada desde 1 e paginação impressa do BR. O documento é fonte de conteúdo; as instruções nele contidas não ampliam a autorização do utilizador para executar acções.
|
||||
|
||||
Não se apresenta este exemplar de 2009 como consolidação normativa verificada até 2026. A eventual verificação de alterações posteriores fica delimitada à norma que venha a ser activada, antes da activação real; não se repetiu pesquisa fiscal nem se consultou o InvesteMoz nesta etapa.
|
||||
|
||||
| Ref. | Fonte/secção efectivamente lida | Conteúdo relevante e limite |
|
||||
| --- | --- | --- |
|
||||
| D1 | Decreto, arts. 1–3, PDF 1–2, BR 372-(15)–(16) | SCE com dois planos: Título I PGC-NIRF, Título II PGC-PE. Art. 2 define âmbito, transições e opção; art. 3 identifica exclusões sectoriais. Não deduzir plano do nome, NUIT ou designação comercial «pequena empresa» |
|
||||
| D2 | Decreto, art. 6 e art. 7, PDF 2, BR 372-(16) | Datas históricas: PGC-NIRF grandes desde exercício iniciado em 01/01/2010; médias e PGC-PE desde 01/01/2011; regra para ano económico não civil e transição. Não usar estas datas como aprovação do plano actual da Tempo Lda |
|
||||
| D3 | Título I, cap. 1.1, §6, PDF 3, BR 372-(17); cap. 1.5, PDF 134, BR 372-(148) | Quadros sintético/detalhado, adaptação às entidades; conta 7.2 Prestação de serviços e subcontas oficiais, incluindo 7.2.1 e 7.2.6. Não inventar desdobramentos oficiais nem tratar todos os nós como contas movimentáveis |
|
||||
| D4 | Título I, cap. 1.2, §20, PDF 5, BR 372-(19) | Base do acréscimo: acontecimento e período, distintos de recebimento/pagamento |
|
||||
| D5 | Título I, NCRF 28, §§3–7 e §§8–16, PDF 126–127, BR 372-(140)–(141) | Âmbito/exclusões, mensuração, componentes identificáveis; prestação de serviços §§14–16 depende de fiabilidade, benefícios, fase de acabamento e custos. «Pontual» não dispensa avaliar condições; locações/construção têm tratamento próprio |
|
||||
| D6 | Título II, cap. 2.1, §§2–4, PDF 168, BR 372-(182) | PGC-PE tem regras simplificadas próprias; complemento referido ao Quadro Conceptual e Glossário do PGC-NIRF. Não importar automaticamente todas as NCRF como se integrassem o PGC-PE |
|
||||
| D7 | Título II, cap. 2.2, §16, PDF 169, BR 372-(183) | Base do acréscimo no corpo do texto. O índice da página anterior apresenta numeração diferente; referência aqui é ao parágrafo efectivamente impresso, não apenas ao índice |
|
||||
| D8 | Título II, cap. 2.4, PDF 178, BR 372-(192); cap. 2.6, PDF 191–192, BR 372-(205)–(206) | 7.2 Prestação de serviços: trabalhos/serviços ligados aos objectivos da empresa; 7.5 rendimentos suplementares e 7.6 outros rendimentos exigem distinguir natureza. 7.2.1 descreve expurgo de IVA de serviços registados com imposto incluído: não fixa taxa nem autoriza esse modo no cálculo actual |
|
||||
|
||||
## 2. Base existente e fronteiras
|
||||
|
||||
Revisão por leitura de `ServiceWorkflow`, `ServicePermissions` e da migração de A: `a_services` é catálogo da empresa, reutilizado entre unidades, com ID, nome, descrição e versão/histórico. Prestações e rascunhos pertencem à unidade autorizada e guardam referências/valores de origem. Categorias contabilísticas, plano empresarial e associações contabilísticas não estão implementados no catálogo actual. Não recriar serviços, clientes, unidades, histórico ou mecanismos de autorização.
|
||||
|
||||
Começar pelo serviço pontual já suportado. O próximo incremento proposto apenas permite preparar, validar e consultar a associação contabilística do catálogo; não implementa reconhecimento ou lançamentos. Mercadorias ficam previstas pelo tipo de item e papéis contabilísticos futuros, sem novos cadastros duplicados, stock, compras, inventário ou custo das vendas nesta entrega.
|
||||
|
||||
Três associações independentes:
|
||||
|
||||
| Dimensão | Pergunta | Saída proposta | O que não determina |
|
||||
| --- | --- | --- | --- |
|
||||
| Classificação contabilística | Qual a natureza económica e quais contas/papéis são adequados? | Plano/versão, categoria, regra e conta aprovada para cada papel | Taxa/tratamento de IVA ou data de reconhecimento |
|
||||
| Enquadramento fiscal | Qual o tratamento da empresa/operação e a taxa aplicável? | Perfil fiscal próprio, fundamento, âmbito e vigência | Conta de rendimentos ou momento de reconhecimento contabilístico |
|
||||
| Reconhecimento | Que acontecimento e evidência satisfazem as condições do normativo? | Avaliação própria de condições, mensuração e período | Não se conclui pela existência de factura, B1 ou recebimento |
|
||||
|
||||
Não assumir 16% para serviços. Não extrair taxa de conta, categoria contabilística, palavras no nome ou código `7.2.1`. O cálculo exacto e a truncagem operacional permanecem nos seus perfis/políticas já delimitados; este desenho não os altera e não escolhe montante de lançamento a partir de um total documental proposto.
|
||||
|
||||
## 3. Proposta técnica de parametrização
|
||||
|
||||
Estas entidades são conceitos propostos, não tabelas criadas ou migrações autorizadas.
|
||||
|
||||
| Elemento | Informação mínima | Âmbito e regra |
|
||||
| --- | --- | --- |
|
||||
| Referência de plano | Identidade PGC-NIRF/PGC-PE, versão documental, hash/fonte, estrutura oficial e estado da verificação | Definição normativa reutilizável, sem partilhar configurações empresariais; códigos sempre qualificados pelo plano/versão, nunca só pelo texto do código |
|
||||
| Plano adoptado pela empresa | Empresa, plano/versão, fundamento, período de aplicação, responsável, decisão e prova | Uma selecção explícita validada; sem escolha automática. Mudança cria versão e exige revisão das associações, sem converter o passado |
|
||||
| Conta empresarial | Plano/versão, código oficial ou desdobramento empresarial distinguido, descrição, pai, natureza, papel, possibilidade de movimento e vigência | Conta pertence à configuração da empresa; conta agregadora não se torna movimentável por omissão. Desdobramento novo exige validação de compatibilidade, sem ocupar código reservado |
|
||||
| Categoria de catálogo | ID, empresa, nome, natureza inicial `service`; previsão de `goods` futura; versão | Reutilizada entre unidades da mesma empresa. Classificação analítica do catálogo não é automaticamente conta nem tratamento de IVA |
|
||||
| Associação/regra | Serviço existente ou categoria, versão da regra, plano/conta para papel `service_revenue`, condições/exclusões, vigência e prova | Específica da empresa. Regra do serviço pode substituir a categoria apenas por decisão explícita validada; sem preferência silenciosa entre regras concorrentes |
|
||||
| Decisão de validação | Proponente, responsável autorizado, data, motivo, evidência, versão aprovada e início de aplicação | Rascunho → validada/activável ou recusada; alteração origina nova versão. Aprovação de classificação não aprova fiscalidade ou reconhecimento |
|
||||
| Resolução por operação futura | ID e versão do serviço/categoria/associação, plano/conta, papel, data de referência, responsável e estado de excepção | Conservar o que foi efectivamente usado, não recalcular pela regra actual; fotografia própria fora de B1 existente |
|
||||
|
||||
Papéis permitem evoluir para rendimentos de mercadorias, inventário e custo, mas nenhuma conta ou regra desses papéis será activada agora. Não criar um motor geral de scripts contabilísticos: condições iniciais são dados delimitados (empresa, tipo serviço, categoria/serviço, papel, intervalo de vigência e exclusões). Fórmulas executáveis, débitos/créditos e contas de contrapartida ficam fora deste incremento.
|
||||
|
||||
### Resolução e vigência propostas
|
||||
|
||||
1. Validar contexto, empresa e acesso; reutilizar ID/versão do serviço. Não permitir associação a categoria/conta/plano de outra empresa.
|
||||
2. Exigir plano empresarial aprovado e uma data de referência explicitamente definida para a classificação. A data é dado próprio, não a data do reconhecimento, exigibilidade de IVA ou data do computador por omissão. Se faltar, apresentar candidato sem o declarar aplicável.
|
||||
3. Só uma excepção específica por serviço **aprovada, vigente na data de referência e compatível** pode substituir a regra da categoria. Proposta pendente, recusada ou com vigência futura não substitui a regra actualmente válida. Na ausência de excepção aplicável, resolver pela regra da categoria aprovada e vigente. A herança deve estar aprovada e visível. Duplicidade, sobreposição ou incompatibilidade geram conflito explícito; não escolher silenciosamente nem ocultar o conflito com uma regra alternativa.
|
||||
4. Validar papel, estado da conta e versão do plano. Conta agregadora ou desdobramento não aprovado não permite resolução como pronta.
|
||||
5. Mostrar conta/regra/fontes e motivo da sugestão. Guardar a decisão e versões na futura operação por acção autorizada, sem produzir lançamento ou concluir reconhecimento.
|
||||
|
||||
O próximo incremento pequeno propõe um único nível de categorias e associação explícita de cada serviço a uma categoria, com excepção específica versionada por serviço. Hierarquias complexas e regras por unidade só entram mediante necessidade futura demonstrada. A classificação da empresa não concede acesso a operações de unidades não autorizadas.
|
||||
|
||||
**Mudança de categoria com efeito contabilístico:** a ligação do serviço à categoria faz parte da classificação versionada. Se uma mudança afectar a conta herdada ou outra componente da classificação, exige proposta, vigência e validação por Samira antes de produzir efeito. A edição corrente do catálogo não pode activar essa mudança, substituir a ligação aprovada ou contornar a validação. Até à entrada em vigor da nova versão aprovada, preserva-se a classificação aplicável anterior e mostra-se a proposta separadamente. As operações históricas conservam as versões utilizadas.
|
||||
|
||||
**Subconjunto inicial de contas:** importar/configurar apenas as referências PGC-NIRF necessárias ao serviço pontual e ao papel de rendimento aprovado, com fonte, versão e hierarquia necessárias identificadas. Não carregar todo o plano nem criar contas ou desdobramentos por suposição. Categorias e associações pertencem à empresa e são reutilizáveis entre unidades; essa reutilização não amplia permissões sobre operações de outras unidades.
|
||||
|
||||
## 4. Automação após validação e excepções
|
||||
|
||||
| Situação | Preenchimento possível após validação | Intervenção/pendência |
|
||||
| --- | --- | --- |
|
||||
| Serviço com regra única vigente aprovada | Reutilizar categoria, papel, conta e versões; explicar origem; conservar a resolução | Não atribuir IVA nem reconhecer rendimento por este preenchimento |
|
||||
| Novo serviço numa categoria com herança aprovada | Sugerir associação da categoria, indicando que é herança | Se a actividade/natureza não coincidir, exigir revisão; o nome sozinho não comprova adequação |
|
||||
| Conta/plano em falta, expirado, substituído ou sem fundamento | Mostrar informação já conhecida e motivo do bloqueio | Responsável resolve antes de marcar a classificação aplicável |
|
||||
| Serviço entre actividade principal, rendimento suplementar, locação, construção ou operação composta | Mostrar alternativas e condições documentais | Rever natureza/âmbito; não escolher 7.2 por ser chamado «serviço» |
|
||||
| Falta de evidência de execução, fiabilidade ou período | Conservar classificação aprovada do catálogo | Reconhecimento continua pendente, mesmo com factura/B1/pagamento |
|
||||
| Regra alterada após preparar operação | Assinalar nova versão disponível, sem substituir a consultada | Revisão explícita; versão anterior identificada como histórica, sem reescrever lançamentos ou B1 |
|
||||
| Operação anterior sem classificação preservada | Mostrar «não capturada» | Eventual classificação posterior será decisão nova datada e auditada, nunca fotografia retrospectiva inventada |
|
||||
| Perfil fiscal ausente ou diferente entre operações do mesmo serviço | Conservar a classificação contabilística adequada | IVA fica pendente ou usa perfil fiscal independentemente validado; nenhuma taxa herdada da conta |
|
||||
|
||||
## 5. Exemplo sintético, sem lançamentos
|
||||
|
||||
Empresa Exemplo, Unidade de Teste, serviço existente `S-TESTE` — «Apoio administrativo pontual». Categoria analítica proposta «Serviços da actividade normal». Não são dados da Tempo Lda.
|
||||
|
||||
Para comparar alternativas, considerar separadamente um cenário com PGC-NIRF e outro com PGC-PE. Ambos mostram **7.2 — Prestação de serviços** nos quadros lidos, mas são referências distintas: `(PGC-NIRF, versão do exemplar, 7.2)` e `(PGC-PE, versão do exemplar, 7.2)`. Não seleccionar um deles nem declarar 7.2 movimentável sem aprovação do plano e da conta empresarial concreta. Se necessário desdobramento empresarial, código e compatibilidade ficam por validar; não inventar um código oficial.
|
||||
|
||||
Depois de um responsável validar a natureza de actividade normal, plano, conta e regra de categoria `R-SERVICO-v1`, o sistema pode preencher uma **proposta de classificação** para o papel de rendimento. Se depois surgir `R-SERVICO-v2`, a operação que utilizou v1 conserva v1 e mostra a alteração; nova operação só resolve v2 na vigência adequada. Não alterar B1 nem rascunhos antigos automaticamente.
|
||||
|
||||
No cenário PGC-NIRF, a análise de reconhecimento teria de avaliar as condições dos §§14–16 da NCRF 28, incluindo fase e custos, não apenas «concluído» ou «facturado». No cenário PGC-PE, usar as bases próprias (cap. 2.2, §16) e validar o tratamento específico; não aplicar a NCRF 28 por simples identidade de código de conta.
|
||||
|
||||
Mesmo serviço/conta pode surgir com enquadramentos fiscais distintos conforme a operação: o exemplo deixa o perfil IVA por validar. Não inclui taxa, cálculo, valor contabilizado, débitos/créditos ou lançamento. Uma confirmação interna e um eventual recebimento não resolvem as pendências de reconhecimento.
|
||||
|
||||
## 6. Próximo incremento pequeno proposto
|
||||
|
||||
Só após aprovação: preparar configuração empresarial do plano validado, selecção de contas autorizadas para o papel de rendimento do serviço pontual, categorias simples e associações versionadas do catálogo. Reutilizar os serviços existentes e apresentar uma resolução explicável, com pendências e histórico. A captura da versão utilizada numa operação deve ter contrato definido agora, mas a sua implementação concreta exige delta próprio que preserve B1 e os modelos actuais; nenhuma alteração de esquema está autorizada por esta especificação.
|
||||
|
||||
Ecrãs propostos: (1) plano e contas da empresa, com fundamento/versão; (2) categorias e associação do serviço existente, com proposta e validação; (3) consulta da resolução e histórico/excepções. Sem ecrã de diário, lançamentos, stock, compras ou fecho. Não construir todo o plano/motor de reconhecimento nesta entrega.
|
||||
|
||||
Critérios de aceitação futuros: plano não presumido; serviço existente reutilizado; conta e plano da empresa correcta; leitor não altera; proponente não aprova sem direito próprio; herança/excepção visíveis; ambiguidades e falta de dados negadas por omissão; regra alterada não reescreve versões antigas; IVA nunca derivado da conta; factura/B1/pagamento nunca reconhecem automaticamente; B1 intacto; demonstração só sintética em ambiente isolado. Testes não executados nesta etapa documental.
|
||||
|
||||
## 7. Decisões pendentes, reduzidas
|
||||
|
||||
### Confirmações posteriores do utilizador — 26/10/2026
|
||||
|
||||
- **Plano actualmente utilizado pela Tempo Lda: PGC-NIRF**, confirmado expressamente pelo utilizador. Não foi escolhido por inferência do nome ou dimensão comercial. Esta informação substitui a pendência de identificar qual dos dois planos é utilizado; não comprova, por si só, a edição/consolidação normativa, o período de adopção ou a configuração concreta de contas da empresa.
|
||||
- **Responsável pela validação dos enquadramentos contabilísticos: Samira**, indicada pelo utilizador. A indicação de responsabilidade não aplica concessões, não altera a conta existente nem aprova automaticamente contas ou associações propostas. A conta exacta e os direitos/âmbitos serão apresentados quando se fechar a matriz de acessos; não se infere identificação por nome.
|
||||
- **Separação de funções confirmada:** a consulta actualmente concedida a Samira não inclui validação contabilística. O futuro poder de validar será próprio, com acções e âmbito delimitados e confirmação específica antes da aplicação. Não implicará automaticamente edição de propostas, edição do catálogo, administração ou gestão de acessos. A proposta deve permitir validar ou recusar uma versão identificada sem modificar o seu conteúdo; uma correcção exige nova proposta por quem tenha o direito correspondente.
|
||||
- Mantém-se a proposta de categoria como regra-base, excepção explícita por serviço e vigência versionada, preservando operações históricas. A identificação de quem propõe e de quem apenas consulta continua pendente. A validação do cálculo permanece separada.
|
||||
|
||||
### Desenho fechado pelo utilizador — confirmação posterior
|
||||
|
||||
Esta confirmação substitui as pendências anteriores relativas a proponente, herança e correcções: **Osmane prepara; Samira valida ou recusa uma versão identificada, sem editar o seu conteúdo. A categoria fornece a regra-base, com excepções explícitas por serviço e vigência definida. Correcções geram nova versão, sem reescrever o histórico.** O plano actualmente utilizado é PGC-NIRF, conforme confirmação anterior.
|
||||
|
||||
O desenho está fechado para preparar o prompt de implementação; não é, por si só, autorização de implementação ou de aplicação de acessos. As novas permissões continuam sujeitas a apresentação concreta de acções, contas e âmbitos e confirmação específica antes da aplicação. Validar não concede edição, administração ou gestão de acessos. A consulta actual permanece inalterada. A validação do cálculo mantém-se separada e pendente.
|
||||
|
||||
Salvaguardas posteriores incorporadas ao desenho fechado: apenas excepções aprovadas/vigentes/compatíveis têm precedência; mudança de categoria que afecte classificação exige versionamento, vigência e validação; categorias/associações empresariais não concedem acesso entre unidades; catálogo inicial limitado ao subconjunto necessário do PGC-NIRF com fonte e versão. Estas salvaguardas devem integrar o prompt de execução sem reabrir as decisões anteriores e sem aplicar concessões nesta etapa.
|
||||
|
||||
### O que resta delimitar
|
||||
|
||||
1. **Versão e configuração do PGC-NIRF:** identificar edição/consolidação, período de adopção e contas/desdobramentos efectivamente utilizados. Confirmar apenas fundamentos e alterações relevantes antes da activação; não voltar a pedir a escolha PGC-PE/PGC-NIRF já resolvida.
|
||||
2. **Procedimento de validação por Samira:** delimitar a evidência e o procedimento para validar contas, desdobramentos e associações. Não voltar a perguntar quem será responsável, nem inventar cargo profissional ou obrigação de revisão por duas pessoas.
|
||||
3. **Aplicação concreta dos acessos:** funções já definidas — Osmane prepara; Samira valida ou recusa. Direitos separados por empresa para consultar, propor/editar rascunho e validar/recusar uma versão identificada. Identificadores candidatos `catalogue_accounting.view`, `.propose`, `.approve`; apresentar contas existentes, acções e âmbitos concretos para confirmação específica antes de conceder. `.approve` não inclui `.propose`, edição, administração ou gestão de acessos. A consulta actual de Samira permanece inalterada e não concede validação. Não inferir direitos de `services.edit/manage`, de permissões fiscais ou de configuração visual; a consulta contabilística específica também não se presume a partir da consulta actual de outras áreas.
|
||||
4. **Detalhe de vigência para execução:** herança por categoria, excepção explícita por serviço, vigência definida e novas versões para correcções já estão fechadas. Resta explicitar no prompt os dados/evidências de referência usados para resolver a vigência, sem voltar a submeter esse desenho a decisão. O recorte de catálogo não aprova política de reconhecimento ou geração de lançamentos.
|
||||
|
||||
Já resolvido e não submetido de novo: catálogo da empresa reutilizável entre unidades; tenant/empresa/unidade distintos; classificação contabilística separada de IVA e reconhecimento; valores exactos, desconhecido diferente de zero e histórico preservado. A validação do cálculo entregue permanece independente e pendente.
|
||||
|
||||
## 8. Alterações realizadas nesta etapa
|
||||
|
||||
Criado apenas este documento de proposta e materiais temporários de leitura em `storage/app/`, fora dos ficheiros de aplicação. PDF original preservado. Não se alteraram código, bases, permissões ou entregas aceites; não foram executados testes, migrações, cálculos ou lançamentos. Nenhuma implementação ou publicação foi iniciada.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Classificação contabilística do catálogo — entrega local
|
||||
|
||||
## Aceitação funcional e encerramento
|
||||
|
||||
Em 26/10/2026, o utilizador informou que entrou na aplicação e verificou o percurso. À pergunta de confirmação da aceitação funcional para registar o encerramento e criar o checkpoint Git local, respondeu **«sim»**. Fica aceite e encerrado este primeiro incremento, no âmbito entregue e com os limites documentados abaixo. O checkpoint preserva a implementação e as dependências locais necessárias, sem credenciais, bases ou dados operacionais locais.
|
||||
|
||||
A validação do cálculo fiscal permanece **separada e pendente**. A presença do seu código no checkpoint preserva a base técnica existente; não constitui aceitação fiscal, activação de perfil real, autorização de emissão ou de lançamentos. Nenhum avanço para outro incremento ou publicação foi autorizado.
|
||||
|
||||
O texto de entrega seguinte descreve o estado técnico anterior à aceitação; esta secção regista a confirmação posterior, sem alterar as provas.
|
||||
|
||||
26 de Outubro de 2026, Africa/Johannesburg. Implementado apenas o primeiro incremento autorizado, com as três salvaguardas de [CATALOGUE_ACCOUNTING_CLASSIFICATION_SPEC.md](CATALOGUE_ACCOUNTING_CLASSIFICATION_SPEC.md). Não constitui aceitação pelo utilizador; a validação do cálculo permanece separada e pendente.
|
||||
|
||||
## Funcionalidade entregue
|
||||
|
||||
- Propostas imutáveis e versionadas de plano PGC-NIRF, contas empresariais, categorias/regras-base, ligações de serviços a categorias e excepções explícitas por serviço. Reutiliza `a_services`; não criou outro catálogo de serviços.
|
||||
- Guardar proposta em preparação, submeter uma versão fixa e validar ou recusar com motivo. Validação não edita conteúdo. Correcção gera outra versão, com referência à anterior, e exige nova submissão/decisão; não herda aprovação. Versão ultrapassada não pode ser submetida ou decidida como se fosse a actual.
|
||||
- Vigência com início explícito e termo opcional inclusivo. Revisão futura ou pendente não substitui regra válida. Intervalos sobrepostos exigem substituição explícita da versão aprovada; conflitos não escolhem automaticamente o maior número de versão. Substituição aprovada termina o intervalo anterior desde o início da nova vigência, sem alterar a linha histórica; a versão substituída não reaparece silenciosamente quando a substituta termina.
|
||||
- Categoria fornece regra-base. Só excepção aprovada, vigente e compatível a substitui. Uma excepção vigente com dependência incompatível mostra pendência/conflito, sem fallback silencioso. Ausência de excepção aplicável usa a categoria aprovada vigente. Mudança de categoria é proposta do tipo ligação, não campo editável pela API corrente de serviços.
|
||||
- Consulta por serviço e data explícita, com origem, conta, versões e pendências. Plano ou conta incompatíveis/agregadores não produzem classificação pronta. Não presume data do computador, taxa de IVA ou reconhecimento.
|
||||
- Resoluções append-only por operação autorizada, com serviço/versão, regra/categoria/conta/plano/decisão e data de referência preservados. A gravação exige a revisão da classificação consultada e a versão da operação; mudança entretanto gera `409`, sem substituir silenciosamente o resultado. Não altera rascunho, fotografia B1 ou cálculo.
|
||||
- Histórico consultável apenas com consulta contabilística e acesso à operação/unidade actual. Não reconstrói classificação nos registos antigos; histórico vazio significa ausência de captura. Rascunho B1 confirmado recusa captura retroactiva.
|
||||
|
||||
Não há lançamentos, débitos/créditos, reconhecimento automático, contas de contrapartida, stock, compras, inventário ou fecho. Perfil/tratamento de IVA continua separado da classificação contabilística. Não foram alterados o núcleo de cálculo, a sua política ou a pré-visualização fiscal.
|
||||
|
||||
## Fonte e configuração inicial
|
||||
|
||||
Decreto n.º 70/2009, exemplar fornecido, cap. 1.5, PDF 134 / BR 372-(148), com hierarquia 7 — Rendimentos e ganhos → 7.2 — Prestação de serviços. Versão de referência: `Decreto70/2009-PGC-NIRF-PDF134-BR372(148)`; SHA-256 do PDF `45C38AED64DE43042ED048586ACCE2EC660B74FC3F8FBCE33D39AAB45C7529E3`.
|
||||
|
||||
Esse é o subconjunto documental inicial, apresentado como referência agregadora não movimentável. Não se importou o plano inteiro. Desdobramento empresarial só é proposto com código, designação, evidência e aprovação; o sistema não inventa uma conta para a Tempo Lda e recusa usar os códigos reservados 7.2/7.2.1/7.2.6 como novos desdobramentos empresariais. A versão do exemplar de 2009 não é apresentada como consolidação normativa actual verificada. A matriz original identifica também os fundamentos de separação de classificação, reconhecimento e IVA; não se repetiu investigação nem se consultou o portal nesta implementação.
|
||||
|
||||
O plano actualmente utilizado pela empresa foi confirmado como PGC-NIRF, mas datas de adopção, contas empresariais e enquadramentos concretos serão propostos por Osmane e validados/recusados por Samira no circuito. Não foram criadas propostas ou aprovações reais para preencher essas lacunas por suposição.
|
||||
|
||||
## Acessos confirmados e efectivamente aplicados
|
||||
|
||||
Após conferência das contas existentes e confirmação específica do utilizador, aplicadas exactamente quatro concessões na Tempo Lda, tenant 1/empresa 1, sem unidade:
|
||||
|
||||
| Conta existente | Direitos novos | Acções |
|
||||
| --- | --- | --- |
|
||||
| ID 1 — Osmane Nala | `catalogue_accounting.view`, `catalogue_accounting.propose` | Consultar, preparar novas versões, submeter e guardar resoluções nas operações de unidades já autorizadas |
|
||||
| ID 2 — Samira Nal | `catalogue_accounting.view`, `catalogue_accounting.approve` | Consultar e validar/recusar versões submetidas, sem editar/preparar |
|
||||
|
||||
Nenhum `.approve` foi dado a Osmane, nenhum `.propose` a Samira; não se acrescentaram direitos de unidade, gestão de acessos, emissão ou reconhecimento. Todos os direitos anteriores permaneceram iguais. [Verificação posterior das concessões](proofs/accounting-access-results.json).
|
||||
|
||||
O código aplica permissões, não privilégios por nome/email. A ferramenta local de concessão recebe IDs explícitos e requer pertenças/direitos de consulta existentes; não cria contas ou pertenças. Novas concessões continuam a exigir apresentação e confirmação específicas. A gestão geral existente não ganhou automaticamente capacidade de delegar estes novos direitos.
|
||||
|
||||
## Migração e preservação
|
||||
|
||||
Delta apresentado antes de aplicar: `2026_10_26_040000_create_catalogue_accounting.php` cria `ca_proposals`, `ca_submissions`, `ca_decisions`, `ca_resolutions`, `ca_commands`, relações compostas, unicidades, checks e triggers. A restrição de âmbito de `permission_grants` foi alargada para aceitar os três identificadores empresariais, sem os conceder na migração.
|
||||
|
||||
Aplicada primeiro em `sgf_tempo_test`, depois em **sgf_tempo_dev** pelo wrapper e proprietário separados. Verificado que era a única migração pendente. Sem reset/fresh de desenvolvimento. Conteúdos existentes e ficheiros do cálculo foram comparados por hashes; permaneceram iguais. As cinco tabelas novas em desenvolvimento continuam com **zero linhas**, mesmo após os quatro acessos. [Preservação da migração](proofs/accounting-dev-preservation.json); [preservação após concessões](proofs/accounting-access-results.json).
|
||||
|
||||
Relações compostas e guardas de inserção recusam referências de outra empresa e capturas de outra unidade/operação. Triggers append-only impedem actualização, transferência em massa e apagamento de propostas, decisões, comandos e resoluções. Submissões/decisões também verificam versão e transição na base. Não se declara isso como RLS ou protecção contra administrador da base. Auditoria e comandos são transaccionais; alterações autorizadas são serializadas pelo bloqueio da empresa, com revalidação de contexto/permissões. O contexto do navegador não constitui autorização.
|
||||
|
||||
## Testes, compilação e navegador
|
||||
|
||||
Suite dirigida inicial: **61 testes / 783 asserções**, passada, cobrindo classificação, A, B1, identidade, isolamento e guardas. Após a protecção adicional da revisão consultada, repetidos apenas os testes afectados de classificação: **9 testes / 397 asserções**, passados. Compilação final Vite 8.3.5 passou. Não se alteraram ferramentas ou dependências, não se repetiu o cálculo fiscal como nova validação.
|
||||
|
||||
Cobertura nova inclui separação de funções; recusa e nova versão; submissão/decisão antigas; repetição de comandos com conflito de conteúdo; herança e excepções pendentes/recusadas/futuras; início de vigência; sobreposição e substituição explícita; mudança de categoria sem bypass pela edição do catálogo; conta agregadora; conta/plano incompatível; referências alheias; revogação no pedido seguinte; isolamento de unidades; transferência em massa e inserção directa cruzada; B1 preservado; data em falta; captura de classificação diferente da consultada recusada. Não houve implementação ou geração de lançamentos.
|
||||
|
||||
Prova concorrente adicional: **dois processos PHP sobrepostos**, um aprova e outro recusa a mesma versão. Um recebeu 200 e o outro 409; existe uma única decisão. [Recibo da concorrência](proofs/accounting-concurrency.json).
|
||||
|
||||
No Edge, instância `http://127.0.0.1:8001`, ligada apenas a `sgf_tempo_test`, sessão/cache isolados e CSRF activo. **Login real com duas identidades sintéticas distintas**, não actingAs como substituto da demonstração. Não se usaram contas reais ou operações fictícias na Tempo Lda. [Registo da observação](proofs/accounting-browser-results.json).
|
||||
|
||||
Percurso observado:
|
||||
|
||||
1. Login do preparador sintético → Empresa Horizonte — Demonstração → Unidade Central — Demonstração → **Classificação contabilística**.
|
||||
2. Na categoria sintética aprovada, **Preparar nova versão** → alterar designação/condições → indicar início 01/11/2026 → seleccionar explicitamente a versão aprovada substituída → indicar fundamento → **Guardar proposta em preparação** → **Submeter versão**. Não havia controlos de validação para esse utilizador.
|
||||
3. Consultar o serviço para 01/11/2026 enquanto a nova proposta aguardava validação: continuou a resolver a categoria v1 aprovada.
|
||||
4. Sair; login da identidade sintética validadora → mesma empresa/unidade → consultar conteúdo fixo → motivo → **Validar esta versão**. Recusar outra proposta submetida com motivo. Não havia formulário de preparação, botão de correcção ou captura para a validadora. [Prova visual](proofs/accounting-validation.png).
|
||||
5. Consultar o serviço em 31/10/2026: categoria v1. Em 01/11/2026: categoria v2 aprovada. Na secção de operações autorizadas, carregar a prestação sintética → **Consultar histórico preservado**: conservava a categoria v1 e referências originais, mesmo após a aprovação de v2. [Histórico preservado](proofs/accounting-history.png).
|
||||
6. Sair. A instância de prova foi encerrada; os testes seguintes só recriaram `sgf_tempo_test`. A aplicação normal manteve a sua ligação.
|
||||
|
||||
A guarda final de revisão foi validada por testes HTTP de aplicação: consultar, aprovar outra regra, tentar guardar a revisão antiga → 409; consultar novamente e guardar → sucesso; repetir o comando → mesmo resultado sem duplicação. A prova visual anterior continua a documentar preparação/decisão/vigência/histórico; não se atribui ao navegador uma demonstração posterior de gravação com o novo token de revisão.
|
||||
|
||||
## Utilização normal
|
||||
|
||||
Actualizar a página/sessão após as concessões e seleccionar Tempo Lda. O menu **Classificação contabilística** aparece para ambos. Não é necessário seleccionar unidade para preparar/validar classificações empresariais; é necessária unidade autorizada para consultar ou guardar resoluções de operações.
|
||||
|
||||
Osmane prepara primeiro a versão documental/período do plano e submete; Samira confere e decide. Depois podem ser propostos e validados a conta empresarial documentada, categoria e ligação ao serviço. O sistema não resolve uma classificação por omissão enquanto faltarem aprovações compatíveis. Se a conta de movimento ou a aplicabilidade estiverem por confirmar, conservar pendência; não usar a referência 7.2 como substituto automático.
|
||||
|
||||
Para operação autorizada, escolher serviço e data → **Consultar classificação**. Para preservar a resolução, carregar operações da unidade, seleccionar a operação e **Guardar resolução na operação**. Se versão ou regras mudaram, consultar/rever antes de guardar; não tentar sobrescrever a história. Samira consulta resoluções sem receber poder de captura ou edição pela validação.
|
||||
|
||||
Ferramentas mantidas por caminhos explícitos no wrapper:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test --stop-on-failure --filter=CatalogueAccountingTest
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
|
||||
```
|
||||
|
||||
Repetir a demonstração apenas por fluxo de testes: parar `scripts/browser-proof.ps1 stop` antes de recriar testes; partir de base de testes vazia produzida pelos testes; `scripts/browser-proof.ps1 start`, seguido de `sgf.ps1 php scripts/accounting-fixture.php --env=testing`. Preparadores recusam dados existentes e não os apagam para prosseguir. `sgf.ps1 php scripts/accounting-concurrency.php --env=testing` demonstra decisões concorrentes. As credenciais sintéticas ficam no ficheiro local protegido já usado pelo preparador e não são publicadas. A verificação de portas não termina processos de outro projecto. O uso de Bypass é apenas no processo PowerShell, sem alteração global.
|
||||
|
||||
## Ficheiros deste incremento e limites
|
||||
|
||||
- Núcleo: `app/Modules/Services/CatalogueAccounting.php`, `Http/CatalogueAccountingController.php` e rotas.
|
||||
- Interface: `resources/js/CatalogueAccountingWorkspace.jsx`, integração/legendas em `app.jsx` e estilos próprios em `app.css`.
|
||||
- Migração: `2026_10_26_040000_create_catalogue_accounting.php`.
|
||||
- Testes: `tests/Feature/CatalogueAccountingTest.php`, `tests/Support/CatalogueAccountingFixture.php`.
|
||||
- Ferramentas/provas: `scripts/accounting-fixture.php`, `accounting-concurrency.php`, `accounting-concurrency-worker.php`, `accounting-preservation.php`, `catalogue-accounting-access.php` e `docs/proofs/accounting-*`.
|
||||
- Documentação: especificação, este relatório, AGENTS e README. Alterações preexistentes foram preservadas; nenhum checkpoint Git foi criado sem pedido.
|
||||
|
||||
Limites: referência histórica fornecida não comprova consolidação normativa actual; contas, datas e regras reais ainda precisam de propostas e validação de Samira. Apenas PGC-NIRF e o papel inicial de rendimento de serviço; sem motor de reconhecimento, diários, contrapartidas, stock/compras ou gestão geral de novas concessões. Sem lançamentos, alteração fiscal, publicação ou avanço para outro incremento. Este relatório não aceita entregas em nome do utilizador.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Preparação documental — entrega local
|
||||
|
||||
Data: 26 de Outubro de 2026 (Africa/Johannesburg). Estado: implementado no âmbito autorizado e pronto para validação própria. B1 aceite em `8fcd52c`, A em `ca13acb` e identidade em `26b80df` permanecem preservados. Não houve reinstalação, mudança de versões, acesso ao InvesteMoz ou publicação.
|
||||
|
||||
## Resultado funcional
|
||||
|
||||
Novo menu **Preparação documental**, com cinco áreas: Dados do emitente, Dados dos clientes, Instruções de pagamento, Preparação de prestações e Preparação de facturação. Os nomes são reutilizados dos cadastros originais como denominação, sem segundo campo redundante. Se a denominação tiver de mudar, edita-se o nome no menu original, segundo os direitos existentes. Não se presume que esse nome já seja uma denominação legal validada.
|
||||
|
||||
Emitente e clientes podem preparar NUIT, morada, localidade e país. Clientes continuam a ser criados apenas com nome no percurso de A; os campos documentais podem ficar por completar. Os cadastros são da empresa e reutilizados entre unidades.
|
||||
|
||||
Nos rascunhos existentes da unidade autorizada podem preparar-se moeda, unidade de medida de cada linha, data/período do serviço, condições de pagamento, vencimento proposto e modo de preço: **Por definir / Imposto incluído / Imposto acrescido**. Datas são validadas como datas e fim de período anterior ao início é recusado. Não há data efectiva de emissão.
|
||||
|
||||
O modo de preço regista uma intenção para preparação futura: não altera os preços ou cálculos existentes, não calcula impostos nem reinterpreta valores históricos. Moeda vazia continua desconhecida. Se o utilizador alterar explicitamente a moeda, o workflow existente do rascunho cria nova versão e captura essa alteração, conservando a precisão e os valores. Essa operação continua a exigir o direito de edição do rascunho em A, além da edição documental; não é um direito implícito novo.
|
||||
|
||||
Condições e vencimento ficam sujeitos a conferência explícita. Sem data de emissão e convenção de contagem aprovadas, não há base para validar automaticamente um prazo contra a data proposta: aparece uma pendência de compatibilidade, sem declarar coerência nem corrigir datas silenciosamente. A indicação livre de «30 dias» não activa uma regra universal.
|
||||
|
||||
## Instruções do emitente para facilitar o pagamento
|
||||
|
||||
Podem configurar-se várias instruções identificadas por designação, com banco, titular, identificador de conta, NIB, IBAN, SWIFT, código bancário e moeda quando aplicáveis, mais texto. A designação é necessária para seleccionar a configuração; os restantes campos não são impostos a instruções que não sejam bancárias. Não existe uma pergunta ao cliente sobre como pagará.
|
||||
|
||||
Cada rascunho pode seleccionar instruções do emitente por identidade, sem redigitar identificadores. O ecrã de selecção mostra designação/versão e explicita que não houve verificação pelo banco. Não inicia operação bancária, não contacta fornecedores, não cria recebimento nem afecta valores.
|
||||
|
||||
Não se importaram contas dos exemplos nem foram definidos valores reais por omissão. O esquema/API não aceita campos de palavras-passe, tokens ou cartões; atribuições óbvias de segredos em texto são recusadas. O formulário informa que não se introduzem credenciais, PIN ou dados de cartões. Isso não constitui um serviço geral de detecção/verificação de dados bancários.
|
||||
|
||||
Alterações têm versão, histórico append-only e auditoria com identificadores/versão, sem valores das instruções. As respostas de erro de escrita e captura não transportam a consulta SQL com dados bancários nem a remetem para os logs da aplicação. Valores necessários ficam apenas nos registos/histórico autorizados e fotografias futuras correspondentes; não entram nos relatórios de prova ou repositório.
|
||||
|
||||
## Reutilização, completude e histórico
|
||||
|
||||
Ao criar um novo rascunho de facturação de uma prestação com preparação documental actual e consulta documental autorizada, reutilizam-se datas, condições, unidades, modo proposto e selecções de instruções. A origem/versão dessa preparação fica registada. Uma preparação de origem desactualizada não é copiada como actual; cadastros/preparações acrescentados depois não são injectados retrospectivamente nos rascunhos ou documentos anteriores.
|
||||
|
||||
O painel **Completude e validações** distingue «Por completar / confirmar aplicabilidade», «Preenchido — não validado», «Validação pendente» e «Origem alterada — rever». Mostra origem e próxima acção, incluindo consulta dos dados relacionados de emitente/cliente. Não há estado «apto para emissão fiscal». Não se construíram campos estatísticos, contratos ou motor geral de parâmetros.
|
||||
|
||||
Editar um rascunho após preparar os dados torna a preparação desactualizada. A confirmação interna exige revisão/actualização dessa preparação. Unidades de medida devem corresponder às linhas actuais; moeda é reutilizada do registo original, não mantida numa segunda fonte independente.
|
||||
|
||||
As fotografias B1 antigas não são actualizadas, reconstruídas ou promovidas a facturas. Para novas confirmações que utilizem preparação documental, o JSON da fotografia e o evento passam ao **esquema 2**, acrescentando `document_preparation`, versões dos registos, completude e cópias das instruções seleccionadas. Sem preparação aplicável mantém-se o formato anterior. O tipo de evento continua a significar confirmação interna, sem validade fiscal.
|
||||
|
||||
Alterações posteriores das instruções/cadastros não alteram fotografias v1 ou v2. A leitura de uma nova fotografia que contenha esses dados exige também consulta documental de emitente, cliente e unidade; apenas consultar facturação em A não passa a expor dados bancários. Rascunhos B1 confirmados continuam bloqueados por API e SQL, incluindo a preparação documental associada.
|
||||
|
||||
## Delta aplicado e preservação
|
||||
|
||||
O delta e destino foram apresentados antes da aplicação. Migração aditiva `2026_10_26_010000_create_document_preparation`, testada em `sgf_tempo_test` e aplicada em `sgf_tempo_dev` pelo wrapper/proprietário separado:
|
||||
|
||||
- `doc_records`: dados de emitente, cliente, instruções e preparação de rascunhos, com tipo, referência ao cadastro original, empresa/unidade e versão.
|
||||
- `doc_history`: versões preservadas, autor e instante; protecção append-only.
|
||||
- Constraints/triggers: fronteira de âmbito e referências, proibição de transferências por operações em massa, versões, selecção de instruções da mesma empresa e bloqueio da preparação de rascunhos confirmados.
|
||||
- Catálogo SQL ampliado apenas para os direitos documentais definidos abaixo; sem concessão automática pela migração.
|
||||
|
||||
Não houve reset/fresh/limpeza destrutiva em desenvolvimento. `document-preservation.php` comparou contagens e resumos criptográficos dos registos existentes antes/depois da migração/concessões: identidade, registos e captura de A e fotografias B1 permaneceram exactamente iguais. Os resumos ficam no directório operacional local, sem valores dos registos no relatório.
|
||||
|
||||
Na verificação final havia duas contas, uma empresa, uma unidade, um cliente e um serviço previamente existentes, sem prestações/rascunhos/confirmações em desenvolvimento; `doc_records` e `doc_history` estavam vazias. Não se criaram dados de demonstração na Tempo Lda/SEGI. A aplicação continua a usar `sgf_dev_app`, sem administração PostgreSQL, e a ligação normal não mudou.
|
||||
|
||||
O rollback automático desta migração é recusado para preservar história; qualquer reversão requer procedimento revisto de preservação. Não foram criadas tabelas de emissão fiscal, recebimentos ou lançamentos.
|
||||
|
||||
## Concessões especificamente confirmadas e aplicadas
|
||||
|
||||
As contas existentes foram conferidas, apresentadas e confirmadas antes da aplicação; nenhuma foi criada ou associada por suposição.
|
||||
|
||||
| Conta | Empresa Tempo Lda, ID 1 | Unidade SEGI, ID 1 |
|
||||
| --- | --- | --- |
|
||||
| ID 1 — Osmane Nala | `issuer_documents.view/edit` (inclui instruções); `client_documents.view/edit` | `draft_documents.view/edit` |
|
||||
| ID 2 — Samira Nal | `issuer_documents.view`; `client_documents.view` | `draft_documents.view` |
|
||||
|
||||
Aplicadas nove concessões, auditadas como `documents.access.initial_grant`; os direitos anteriores foram preservados. Não incluem gestão dos novos direitos, emissão, recebimento ou contabilização. Nomes/emails não determinam privilégios durante a execução. A API revalida contexto, pertenças, permissões e versão; revogação de consulta impede o pedido seguinte, inclusive a leitura de fotografias v2 com instruções.
|
||||
|
||||
`scripts/document-access.php` é procedimento local de pré-visualização/aplicação; não executar `--apply` como seed ou sem confirmação específica de nova operação. O procedimento usado recebeu os IDs explicitamente confirmados. `scripts/document-status.php` mostra contagens e direitos, sem valores das instruções.
|
||||
|
||||
## Verificações e provas
|
||||
|
||||
- **76 testes / 683 asserções aprovados** na suite completa, incluindo identidade, A e B1. As aceitações anteriores não foram reabertas.
|
||||
- Compilação Vite aprovada: 15 módulos, sem alteração de dependências, versões ou lockfiles.
|
||||
- Testes de cliente só com nome, denominação reutilizada, cadastros partilhados, instruções seleccionadas, herança da preparação, moeda desconhecida, precisão exacta, preenchido distinto de validado, períodos inválidos, pendência de condições/vencimento, versão/contexto antigos, origem alterada, acesso cruzado, leitura sem edição, ausência de direitos implícitos, revogação, SQL em massa, histórico append-only e rollback integral com falha injectada.
|
||||
- Compatibilidade: fotografia antiga sem esquema 2 preservada byte a byte pela resposta; nova fotografia v2 conserva instrução/versão seleccionada após alteração da configuração; consulta sem direito ao emitente é recusada. Eventos novos com conteúdo documental declaram esquema 2.
|
||||
- Falha de escrita com identificador sintético aleatório: nenhum valor incluído no erro, auditoria ou log técnico. Os relatórios de prova omitem os identificadores.
|
||||
- Prova HTTP com login real sintético: reutilização, fotografias v1/v2, recusas 403/409, selecção sem recebimento e ausência de reinterpretação monetária. [Resultado](proofs/document-http-results.json).
|
||||
- Prova no navegador, com CSRF e sessões/cache isolados: gestor actualizou emitente/cliente, consultou dados herdados, seleccionou instruções e guardou o modo proposto. Conta de consulta entrou pelo formulário, viu os dados e instruções seleccionadas, sem botão Guardar e com edição desactivada. A mudança entre separadores revelou uma falha de transição React, corrigida e novamente verificada.
|
||||
|
||||
Provas: [emitente e completude](proofs/document-01-issuer-profile.png), [instruções reutilizadas sem recebimento](proofs/document-02-reused-instructions.png), [consulta com contexto sintético](proofs/document-03-read-only.png), [métricas sem valores das instruções](proofs/document-capture.json).
|
||||
|
||||
A instância isolada usou apenas `sgf_tempo_test` na porta 8001. Foi parada antes da suite final que limpa os dados de teste. A aplicação normal permaneceu em `sgf_tempo_dev` na porta 8000; não se terminaram processos de outros projectos.
|
||||
|
||||
## Navegação exacta
|
||||
|
||||
1. Actualizar o navegador e entrar como utilizador autorizado; seleccionar Tempo Lda / SEGI. Se os novos direitos não aparecerem, sair e voltar a entrar.
|
||||
2. Abrir **Preparação documental → Dados do emitente**, completar e guardar. O nome é alterado em **Empresa**, segundo o direito existente.
|
||||
3. Abrir **Dados dos clientes**, seleccionar um cliente já criado em **Clientes**, completar os dados e guardar. Não duplicar o cadastro.
|
||||
4. Abrir **Instruções de pagamento → Novas instruções**, preencher apenas os dados aplicáveis, dar uma designação e guardar. Sem segredos ou dados de cartões.
|
||||
5. Criar uma prestação no menu **Prestações em rascunho**, se ainda não existir. Voltar a **Preparação documental → Preparação de prestações**, seleccioná-la, completar campos e seleccionar as instruções aplicáveis; guardar.
|
||||
6. Abrir a prestação em **Prestações em rascunho → Preparar rascunho de facturação**. Em **Preparação documental → Preparação de facturação**, seleccionar o rascunho e conferir os dados reutilizados e pendências. Pode também preparar directamente um rascunho existente ainda não confirmado.
|
||||
7. A confirmação interna B1 continua em **Rascunhos de facturação → abrir registo → Rever confirmação interna**; não é emissão. Nenhuma preparação pode editar um rascunho já confirmado.
|
||||
|
||||
## Ficheiros e comandos
|
||||
|
||||
Novos: `DocumentPreparation.php`, `Http/DocumentPreparationController.php`, `DocumentPreparationWorkspace.jsx`, migração indicada, `DocumentPreparationTest.php`, fixture documental e scripts `document-access.php`, `document-status.php`, `document-preservation.php`, `document-http-proof.mjs`, `document-proof-metrics.php`. Integração em rotas, navegação, CSS, criação de rascunho de facturação e captura/leitura de confirmações internas. A preparação de fixtures do navegador passou a conceder os novos direitos apenas a identidades sintéticas.
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/document-status.php
|
||||
```
|
||||
|
||||
Testes destrutivos permanecem exclusivos de `sgf_tempo_test`. Scripts de métricas verificam esse destino. Parar a instância de navegador de testes antes da suite. Nenhum exemplo real deve ser usado como fixture, configuração ou referência bancária por omissão.
|
||||
|
||||
## Pendências e limites
|
||||
|
||||
Nenhuma fonte legal/fiscal nova foi validada neste incremento. Obrigatoriedade por regime/tipo, validade de NUIT/moradas, numeração, emissão, taxas, incidência e arredondamento fiscal permanecem pendentes. Contas, reconhecimento, moeda funcional e processamento contabilístico também. A preparação não se apresenta como validada pelo banco ou apta para emissão fiscal.
|
||||
|
||||
Não há emissão de factura, recibo ou factura-recibo, data efectiva de emissão, número fiscal, PDF de factura, recebimento, afectação ou lançamento. Descontos, contratos, estatísticas e dimensões analíticas não foram implementados. Não existe eliminação de configurações ou gestão das novas concessões pela interface. Restantes incrementos e publicação continuam sem autorização; esta entrega aguarda validação do utilizador.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Primeira pré-visualização documental — entrega técnica
|
||||
|
||||
Data: 26 de Outubro de 2026 (Africa/Johannesburg). Implementação e provas concluídas; **concessão real de configuração especificamente confirmada e aplicada**. Entrega pronta para validação do utilizador, sem a declarar aceite, nem à preparação documental anterior. Preservar identidade `26b80df`, A `ca13acb`, B1 `8fcd52c` e as alterações posteriores ainda não incluídas num novo checkpoint.
|
||||
|
||||
## Funcional
|
||||
|
||||
Em **Rascunhos de facturação → abrir rascunho → Pré-visualizar rascunho**, a aplicação apresenta dados reutilizados do emitente, cliente, linhas, preparação de datas/condições/moeda/unidades e instruções seleccionadas. Não se redigitam cadastros ou identificadores bancários. A apresentação configura-se separadamente em **Apresentação do rascunho**, no âmbito da empresa.
|
||||
|
||||
O aviso exacto **RASCUNHO — SEM VALIDADE FISCAL** fica fixo no topo da janela, em desktop e ecrã pequeno, e repete-se na folha visual. A configuração não dispõe de campos para alterar/ocultar aviso ou pendências; a API e o trigger SQL recusam campos fora do catálogo. As regras de estilo para a impressão normal do navegador mantêm aviso e pendências. Não existe função de impressão, exportação ou PDF no SGF neste incremento.
|
||||
|
||||
Os rótulos são **Preço proposto**, **Valor aritmético provisório**, **Imposto por apurar** e **Total definitivo por apurar**. O valor aritmético reutiliza a operação decimal exacta já existente. Falta de quantidade/preço mantém valor desconhecido, enquanto zero explicitamente guardado continua zero. Imposto e total definitivo mostram «Não apurado». Moeda ausente mostra «Moeda por definir»; não se presume MZN, não se somam moedas diferentes e não se decompõe preço com imposto incluído desconhecido. Nenhuma base aritmética é promovida a base tributável.
|
||||
|
||||
Campos em falta e pendências mantêm rótulos próprios, incluindo validação legal/fiscal, moeda, modo de preço, condições/vencimento e origem alterada. Dados preenchidos não passam a fiscalmente validados. O instante de consulta é rotulado como consulta, distinto de data/período do serviço e vencimento proposto. Não há data efectiva de emissão, número fiscal ou estado emitido.
|
||||
|
||||
## Configuração delimitada e recursos
|
||||
|
||||
O modelo por empresa tem versão e histórico append-only. Permite apresentar/omitir os blocos opcionais de logótipo, contactos, observações e instruções seleccionadas. Omitir blocos não elimina as pendências de negócio/documentais. Contactos e observações são texto de apresentação configurado uma vez por empresa; não havia campos de contacto empresarial nesses cadastros, e não se usa automaticamente o email privado do utilizador.
|
||||
|
||||
As instruções aplicáveis são as selecções já guardadas na preparação de cada rascunho; não se criam novas escolhas de meio de recebimento, cobranças ou ligações bancárias. A configuração só controla a apresentação desse bloco. A leitura de valores completos exige os direitos existentes e não os envia para logs, URLs ou relatórios de prova.
|
||||
|
||||
**Limitação identificada antes da implementação:** não havia logótipo empresarial nos recursos existentes. A opção de apresentação funciona, mas sem recurso surge «Logótipo opcional não disponível». Não se extraiu uma marca das imagens nem se criou/importou uma identidade visual. O resolvedor aceita apenas PNG local autorizado, até 512 KiB, do disco privado local no caminho fixo `document-logos/company-<ID>.png`; valida assinatura/MIME e inclui os bytes na resposta, sem pedido externo. Não há upload, URL configurável ou escolha arbitrária de ficheiro. Qualquer colocação futura de imagem deve ter autorização própria; não copiar os exemplos como recurso por omissão.
|
||||
|
||||
Contactos, observações, descrições e instruções são renderizados como texto escapado por React, sem HTML executável, links automáticos ou recursos externos. O modelo não permite HTML/CSS livre, regras de cálculo ou campos personalizados. O conteúdo não sai para serviços de renderização de terceiros.
|
||||
|
||||
## Consulta coerente e actualização explícita
|
||||
|
||||
Cada composição HTTP inicia uma transacção PostgreSQL **REPEATABLE READ READ ONLY** antes de ler dados e permissões. Configuração, cadastros, linhas, pendências e instruções vêm da mesma fotografia de leitura. A consulta não cria registos, resolve pendências, audita uma alteração de negócio, confirma ou emite documentos. A configuração é uma operação de escrita distinta, autorizada e auditada.
|
||||
|
||||
A resposta inclui uma revisão HMAC e metadados de versão; o instante da consulta fica fora dessa revisão. O endpoint de verificação devolve só a revisão. No navegador, verifica-se a cada dez segundos enquanto visível e ao regressar à janela. Uma mudança mostra **Pré-visualização desactualizada**, mantendo os valores já apresentados; só **Actualizar pré-visualização** substitui o conteúdo. Falha/recusa de nova consulta remove o conteúdo. A verificação é periódica, não uma notificação instantânea por push.
|
||||
|
||||
As consultas e revisões revalidam contexto, pertenças, empresa/unidade e permissões no servidor; respostas têm `Cache-Control: no-store`. A mudança de contexto desmonta a vista e elimina o estado anterior da interface. Não há cache partilhada nem reutilização de dados entre empresas/unidades. A revisão é autenticada com chave do servidor, sem permitir adivinhar identificadores bancários a partir de um hash simples.
|
||||
|
||||
## Histórico B1 separado
|
||||
|
||||
Em registos confirmados, a acção é **Abrir confirmação interna histórica**. O endpoint de rascunho recusa esse registo com 409 e indica a vista histórica. A nova vista usa exclusivamente a fotografia B1 preservada para o conteúdo, incluindo linhas, valores e dados documentais/instruções que tenham sido capturados no esquema 2. Não lê a configuração actual para enriquecer a história.
|
||||
|
||||
Campos não capturados mostram **Não capturado nesta confirmação**. As fotografias existentes não contêm logótipo/contactos/observações do modelo nem a denominação do emitente: não se completa essa informação a partir da empresa actual. A denominação actual no cabeçalho geral da aplicação indica apenas o contexto autorizado; não é inserida no documento histórico. A vista distingue autor/instante de confirmação, instante desta consulta, esquema da fotografia e ausência de modelo histórico capturado.
|
||||
|
||||
Nenhuma fotografia foi alterada ou reconstruída. As vistas antigas/esquema 2 continuam distintas de uma factura emitida. O mesmo aviso de ausência de validade fiscal acompanha a consulta histórica.
|
||||
|
||||
## Migração e preservação
|
||||
|
||||
Delta aditivo apresentado antes da aplicação: `2026_10_26_020000_create_document_preview_models`, testado em `sgf_tempo_test` e aplicado em `sgf_tempo_dev` pelo wrapper/proprietário separado.
|
||||
|
||||
- Cria `document_preview_models` (empresa, configuração, versão) e `document_preview_model_history` (versão preservada, autor, instante).
|
||||
- Acrescenta constraints/triggers para pertença imutável, versão, campos configuráveis fixos e histórico append-only.
|
||||
- Permite `document_preview.configure` apenas no âmbito da empresa; a migração não concede o direito.
|
||||
- Não cria emissão, numeração, datas fiscais, impostos, recebimentos ou lançamentos. Rollback automático recusado para preservar o histórico; eventual reversão exige procedimento revisto.
|
||||
|
||||
`document-preservation.php` foi alargado para comparar também `doc_records/doc_history`. A comparação antes/depois da migração confirmou conteúdo e contagens exactamente iguais em identidade, A, B1 e preparação documental. Não houve reset/fresh/limpeza destrutiva de desenvolvimento. Após a aplicação, as duas tabelas de modelo estavam vazias em desenvolvimento; não se criaram configurações de demonstração na Tempo Lda. A aplicação normal manteve a ligação a `sgf_tempo_dev` e o papel `sgf_dev_app` sem administração PostgreSQL.
|
||||
|
||||
## Acessos confirmados e aplicados
|
||||
|
||||
Consulta de rascunho reutiliza os direitos explícitos existentes: consulta da empresa/unidade, facturação, clientes e dados documentais de emitente/cliente/unidade. Consulta de configuração usa `issuer_documents.view`. Histórico v1 conserva a fronteira de consulta B1; conteúdo v2 com dados documentais exige também esses direitos de consulta. Nenhuma acção de leitura concede configuração ou emissão.
|
||||
|
||||
Contas existentes conferidas: ID 1 — Osmane Nala; ID 2 — Samira Nal. Operação concreta apresentada para confirmação: **conceder apenas `document_preview.configure` ao ID 1 na Tempo Lda, empresa ID 1, unidade nula**. Samira mantém os direitos de consulta existentes, sem novo direito de configuração. Não há gestão desse novo direito pela interface ou poder financeiro derivado.
|
||||
|
||||
**Estado efectivo:** o utilizador respondeu «Confirmar exactamente esta concessão». A conta e a empresa foram novamente conferidas; `preview-access.php 1 1 --apply` aplicou apenas `document_preview.configure` à conta 1 na empresa 1, com auditoria `document_preview.access.initial_grant`. A verificação posterior confirmou exactamente essa concessão, com unidade nula. Samira não recebeu configuração, os restantes direitos foram preservados e nenhuma configuração de demonstração foi criada em desenvolvimento. Não repetir a aplicação sem confirmação específica de uma nova operação.
|
||||
|
||||
## Verificação
|
||||
|
||||
- Suite completa: **89 testes / 893 asserções aprovados**, incluindo regressões da fundação e entregas anteriores. Aceitações não foram reabertas.
|
||||
- Compilação final Vite aprovada: 16 módulos, sem mudança de versões, manifests ou lockfiles.
|
||||
- Execução final apenas dos 13 testes novos: **13 testes / 210 asserções aprovados**. A prova de navegador foi parada antes dessa execução, que também limpa as fixtures de teste.
|
||||
- Testes de reutilização, consulta sem escritas, desconhecido distinto de zero, aritmética exacta, direitos explícitos, recusa de configuração para consulta, versões antigas, aviso/pendências não configuráveis, recurso externo recusado, revisão alterada, campos históricos não reconstruídos, acesso cruzado/revogação, PNG local, imutabilidade SQL e rollback de configuração.
|
||||
- Prova concorrente com processos PHP/PostgreSQL: isolamento `repeatable read` verificado; uma consulta em curso manteve ambas as versões antigas enquanto outra transacção alterava emitente/cliente; nova consulta viu ambas as versões novas, sem mistura. [Resultado](proofs/preview-coherence.json).
|
||||
- HTTP com login real sintético, CSRF e sessões distintas: configuração autorizada, leitor 403, campos proibidos 422, versão/contexto antigos 409, revisão sem conteúdo sensível e história independente do modelo actual. [Resultado](proofs/preview-http-results.json).
|
||||
- Navegador: configuração pelo gestor sintético, leitura de rascunho, mudança em segundo separador com aviso sem substituir o texto antigo, actualização explícita, consulta histórica, leitor sem botão Guardar e remoção da vista após mudar unidade. Ecrã real de 390 × 844, sem transbordo horizontal, aviso visível no topo durante a rolagem. [Registo sem valores bancários](proofs/preview-browser-results.json).
|
||||
|
||||
Provas visuais: [desktop](proofs/preview-01-desktop.png), [desactualização](proofs/preview-02-stale.png), [ecrã pequeno](proofs/preview-03-small-screen.png), [histórico](proofs/preview-04-historical.png), [histórico em ecrã pequeno](proofs/preview-05-history-small.png), [configuração em consulta](proofs/preview-06-reader-configuration.png). As provas omitem valores completos das instruções; o bloco foi desactivado apenas na configuração sintética, mantendo as pendências. O comportamento de apresentação de instruções seleccionadas foi verificado por testes e HTTP.
|
||||
|
||||
As provas usaram exclusivamente a instância na porta 8001 e `sgf_tempo_test`, sem mudar a ligação normal ou introduzir demonstrações na Tempo Lda. A dimensão temporária do navegador foi reposta; os separadores de prova foram fechados. Sem publicação ou processos alheios terminados.
|
||||
|
||||
## Ficheiros e navegação
|
||||
|
||||
Novos: `app/Modules/Services/DocumentPreview.php`, `Http/DocumentPreviewController.php`, `resources/js/DocumentPreview.jsx`, migração indicada, `DocumentPreviewTest.php`, fixture própria e scripts `preview-access.php`, `preview-status.php`, `preview-http-proof.mjs`, `preview-coherence-proof.php`, `preview-coherence-worker.php`. Integração em rotas, navegação, ServicesWorkspace e CSS; fixture de navegador e verificação de preservação adaptadas.
|
||||
|
||||
1. Actualizar o navegador e entrar; seleccionar **Tempo Lda / SEGI**.
|
||||
2. Se ainda não houver rascunho, usar o percurso existente: **Clientes → Serviços → Prestações em rascunho → abrir prestação → Preparar rascunho de facturação**; completar os dados em **Preparação documental**, conforme necessário.
|
||||
3. **Rascunhos de facturação → seleccionar um rascunho não confirmado → Pré-visualizar rascunho**.
|
||||
4. Se houver aviso de alteração, usar **Actualizar pré-visualização**. A abertura/actualização não confirma nem emite.
|
||||
5. **Apresentação do rascunho** permite consultar a configuração. Osmane pode guardar a apresentação; Samira permanece em consulta. Actualizar a sessão, saindo e entrando novamente se necessário, para carregar a concessão aplicada.
|
||||
6. Para história: **Rascunhos de facturação → seleccionar registo confirmado → Abrir confirmação interna histórica**. Não completar campos históricos com dados actuais.
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/preview-status.php
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test --filter DocumentPreviewTest
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build
|
||||
```
|
||||
|
||||
## Limites
|
||||
|
||||
Apenas navegador. Sem PDF/exportação/impressão documental, emissão fiscal, data efectiva de emissão, número fiscal, novo cálculo de impostos, recibos, recebimentos, afectações, reconciliação ou lançamentos. Não se validaram novas fontes legais/fiscais ou regras de arredondamento. O modelo não é editor geral nem prova de conformidade. Não há recurso empresarial de logótipo instalado, carregamento externo ou upload. O restante âmbito e publicação continuam sem autorização. Esta entrega aguarda validação do utilizador.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Pré-visualização de factura de serviço — plano para aprovação
|
||||
|
||||
Data: 26 de Outubro de 2026 (Africa/Johannesburg). Apenas plano, critérios e decisões; não autoriza implementação ou publicação.
|
||||
|
||||
Actualização posterior: o utilizador fechou as decisões de configuração limitada, rótulos, actualização explícita e história B1 separada e autorizou a primeira implementação por prompt próprio. A execução está em [DRAFT_INVOICE_PREVIEW_DELIVERY.md](DRAFT_INVOICE_PREVIEW_DELIVERY.md). A concessão efectiva de configuração é uma confirmação específica separada; o restante âmbito fiscal/contabilístico continua sem autorização.
|
||||
|
||||
Preservar a preparação documental entregue e o trabalho posterior ainda não incluído num novo checkpoint. Preservar identidade `26b80df`, A `ca13acb` e B1 `8fcd52c`, com as respectivas aceitações. A preparação documental tem relatório próprio e não é declarada aceite por este plano. Não alterar código, bases, permissões, fotografias ou versões nesta etapa. Não foram repetidos testes.
|
||||
|
||||
Referências: SGF_TEMPO_Roteiro_v0_9.docx, BILLING_DOCUMENTS_FIELD_SPEC.md, DOCUMENT_PREPARATION_DELIVERY.md e SERVICES_B1_DELIVERY.md. Os três exemplos informam a organização do conteúdo, sem limitar o modelo ou validar obrigações legais.
|
||||
|
||||
## Próximo incremento proposto
|
||||
|
||||
Acrescentar uma pré-visualização de leitura do **rascunho de facturação de serviço**, dentro da aplicação. Reutilizar dados existentes, sem duplicar cadastros, recolher novamente instruções de pagamento ou criar um novo documento emitido. Proposta inicial: um modelo comum, configurável de forma limitada e com versão identificada, apresentado no navegador. Sem PDF, exportação ou função de impressão neste incremento.
|
||||
|
||||
Mostrar permanentemente **RASCUNHO — SEM VALIDADE FISCAL** no cabeçalho e num aviso visível durante a navegação/rolagem. Repetir a indicação nas páginas visuais do documento. O aviso não pode ser removido por configuração. Uma eventual impressão pelo próprio navegador não é uma capacidade fiscal do SGF; a folha de impressão deve conservar o aviso. Não há número fiscal, data efectiva de emissão, assinatura fiscal ou estado de factura emitida.
|
||||
|
||||
Percurso proposto: **Rascunhos de facturação → abrir registo → Pré-visualizar rascunho**. A pré-visualização permite voltar aos menus de preparação autorizados; não edita silenciosamente os dados nem oferece botões de emissão, recebimento ou contabilização.
|
||||
|
||||
## Conteúdo e origem
|
||||
|
||||
| Bloco | Origem | Apresentação proposta / informação em falta |
|
||||
| --- | --- | --- |
|
||||
| Identificação | Rascunho e contexto autorizado | Referência interna claramente rotulada, empresa/unidade, versão do rascunho e da preparação. Sem converter INT-B1 ou ID técnico em número de factura |
|
||||
| Emitente | Nome da empresa e preparação documental | Denominação reutilizada, NUIT, morada, localidade e país quando preenchidos. Campo relevante vazio: «Por completar», sem inventar identificação |
|
||||
| Cliente | Cliente seleccionado e preparação documental da empresa | Mesmo cadastro e versões, sem cópia por unidade. Nome basta para começar; restantes dados em falta permanecem visíveis |
|
||||
| Serviço e origem | Prestação/rascunho existentes | Descrição, origem e versões. Não afirmar execução confirmada do serviço |
|
||||
| Linhas | Linhas guardadas e unidades de medida preparadas | Serviço, descrição, quantidade, unidade e preço proposto. Desconhecido é «Por completar»; zero explicitamente guardado continua zero |
|
||||
| Datas | Preparação documental | Data/período do serviço e vencimento **proposto**, separados. Condições e vencimento com pendência de compatibilidade; não inferir emissão ou competência contabilística |
|
||||
| Moeda | Moeda guardada no rascunho | Código indicado, com validação pendente, ou «Moeda por definir». Nenhuma moeda por omissão |
|
||||
| Modo de preço | Preparação guardada | «Por definir», «Imposto incluído — indicação proposta» ou «Imposto acrescido — indicação proposta». Não altera os preços existentes |
|
||||
| Valores | Valores exactos já existentes | Preços propostos e, se disponível, valor aritmético já calculado pelo percurso existente, com rótulo provisório. Não chamar base tributável à base aritmética de A/B1 |
|
||||
| Impostos e total | Pendências existentes; sem motor fiscal | «Impostos não apurados» e «Total fiscal / montante definitivo: não apurado». Não mostrar imposto zero ou fabricar um total pela ausência de regra |
|
||||
| Instruções para pagamento | Configurações do emitente explicitamente seleccionadas | Dados aplicáveis reutilizados, sem redigitação; designação/versão e indicação de introdução pelo utilizador, sem verificação bancária. Sem selecção: «Instruções não seleccionadas», nunca «pagamento pendente/recebido» |
|
||||
| Completude | Verificações documentais existentes | Separar «Por completar», «Preenchido — não validado», «Validação pendente» e «Origem alterada». Mostrar origem e próxima acção |
|
||||
|
||||
Sem moeda definida, qualquer expressão aritmética existente deve ser identificada como valor sem unidade monetária validada, sem símbolo monetário ou total definitivo. Não agregar moedas distintas. A escolha de imposto incluído/acrescido não autoriza decompor o preço, aplicar taxa, arredondar ou relançar valores anteriores. Não introduzir novos cálculos de impostos, descontos, câmbio ou totais fiscais.
|
||||
|
||||
Dados adicionais de gestão/estatísticas, extractos de conta, publicidade, contratos, descontos e logística ficam fora deste incremento. Dados bancários seleccionados são instruções do emitente para facilitar pagamento, não uma pergunta sobre como o cliente pagará.
|
||||
|
||||
## Modelo configurável proposto
|
||||
|
||||
Usar um núcleo de conteúdo comum e configuração limitada de apresentação, sem motor geral de campos personalizados. Não reproduzir obrigatoriamente as colunas, marcas, bancos ou rodapés das imagens fornecidas.
|
||||
|
||||
Proposta mínima de configuração: ordem dos blocos opcionais, visibilidade de dados opcionais já autorizados e rótulos de apresentação não fiscais. Modelo base por empresa, com versão; eventual logótipo só se houver activo autorizado disponível. Não criar agora um editor livre de HTML, uploads ou integrações. A leitura/configuração do modelo não altera regras de negócio ou permissões sobre o conteúdo.
|
||||
|
||||
Não configuráveis: aviso de rascunho, ausência de emissão, distinção entre provisório e definitivo, indicação de impostos não apurados, identificação das origens/versões e pendências relevantes. Ocultar um campo opcional do desenho não resolve nem elimina uma pendência no painel de completude. Dados em falta não devem ser ocultados para produzir aparência de documento completo.
|
||||
|
||||
Propor um desenho sóbrio com identificação das partes, linhas, datas e condições, secção de valores provisórios, instruções e pendências. O painel de trabalho pode apresentar metadados de contexto e versões separado da folha visual. A configuração não é um mecanismo de autorização nem prova de conformidade fiscal.
|
||||
|
||||
## Actualidade e preservação histórica
|
||||
|
||||
- **Rascunho editável:** proposta de leitura conjunta coerente das versões actuais autorizadas, com indicação das versões de empresa/cliente/preparação/linhas/instruções e instante de consulta rotulado como tal, nunca emissão. A confirmação de geração não deve gravar nova fotografia B1 nem alterar estados.
|
||||
- Se os dados mudarem enquanto a pré-visualização estiver aberta, indicar «Pré-visualização desactualizada — actualizar». A actualização é explícita; não substituir silenciosamente valores ou instruções já visíveis. A estratégia de detecção (ao regressar/recarregar, sem actualização automática contínua) deve ser aprovada antes de implementar.
|
||||
- **Registo já confirmado B1:** não montar um documento aparentemente histórico com cadastros actuais. Proposta: manter a consulta histórica B1 separada e usar exclusivamente a fotografia guardada. Formato antigo sem determinado campo mostra «Não capturado nesta confirmação», sem o ir buscar ao cadastro actual. Esquema 2 mostra apenas os dados capturados na sua versão.
|
||||
- Não chamar à consulta B1 uma factura ou emissão. Manter «Confirmado internamente — sem validade fiscal» e, se adoptar a mesma representação visual, conservar também o aviso de rascunho. Não alterar fotografias antigas para acomodar o modelo.
|
||||
- O modelo de apresentação pode evoluir sem modificar os dados de origem. Uma representação histórica não é prova de que essa versão do modelo já existia na confirmação: mostrar separadamente versão da fotografia e versão do modelo usado na consulta.
|
||||
|
||||
## Acessos e protecção
|
||||
|
||||
Proposta: reutilizar **apenas consulta** já explicitamente concedida, sem criar poder de emissão: consulta de facturação no âmbito da unidade, `draft_documents.view` nessa unidade, `issuer_documents.view` e `client_documents.view` na empresa. Confirmar a matriz exacta antes da implementação; editar preparação ou configurar um modelo não resulta desta acção de consulta.
|
||||
|
||||
O servidor revalida utilizador, pertenças, contexto, unidade e referências em cada pedido. Identificadores enviados pelo navegador não autorizam dados. Revogação impede a consulta no pedido seguinte; não misturar dados de empresas do mesmo tenant ou de outra unidade.
|
||||
|
||||
Valores completos das instruções de pagamento só aparecem na apresentação autorizada quando seleccionados; não entram em URLs, logs, erros, telemetria, relatórios de testes ou repositório. Evitar cache partilhada e dados de um contexto anterior ao mudar empresa/unidade ou sair. Escapar texto e impedir HTML/script introduzido em instruções ou descrições. Provas futuras com dados sintéticos em instância isolada, mantendo valores de instruções fora das capturas de prova a versionar.
|
||||
|
||||
## Plano de implementação futuro, sujeito a aprovação
|
||||
|
||||
1. Fechar decisões abaixo e aprovar um exemplo de organização visual com campos fictícios/sem identificadores bancários reais, sem avançar para emissão.
|
||||
2. Compor uma resposta de leitura com conteúdo e proveniência/versões, reutilizando workflows existentes. Definir a política de actualidade e acesso; não persistir um novo documento fiscal.
|
||||
3. Renderizar o modelo base e painel de pendências, com configuração limitada e aviso obrigatório; separar consulta de rascunho actual e fotografia B1.
|
||||
4. Testar a composição, a confidencialidade e a apresentação; demonstrar no navegador isolado o percurso com dados sintéticos. Executar apenas regressões justificadas pelas alterações, quando a implementação for autorizada.
|
||||
5. Entregar ficheiros, provas, limitações e instruções de navegação. Não aplicar novas concessões, migrações ou criação de configuração sem apresentar o delta/âmbito correspondente. Não publicar.
|
||||
|
||||
## Critérios de aceitação propostos
|
||||
|
||||
1. Um rascunho de serviço existente é apresentado sem redigitar emitente, cliente, linhas, datas, moeda ou instruções seleccionadas e sem criar outro cliente/documento/recebimento.
|
||||
2. O aviso exacto **RASCUNHO — SEM VALIDADE FISCAL** mantém-se visível durante a leitura, em páginas visuais e no estilo de impressão; nenhuma configuração o remove.
|
||||
3. Campos em falta, dados preenchidos sem validação e regras pendentes têm estados/rótulos distintos; zero explícito não é confundido com desconhecido. Nenhum estado afirma aptidão fiscal.
|
||||
4. Datas de serviço/período, vencimento proposto e instante de consulta aparecem distintos; não existe data efectiva de emissão, número fiscal ou estado emitido.
|
||||
5. Valores mantêm as cadeias decimais exactas de origem. Moeda/mode desconhecidos não são presumidos; modos incluído/acrescido não geram taxa, imposto, base tributável ou total fictícios. Impostos/total definitivo ficam não apurados.
|
||||
6. Só as instruções seleccionadas da empresa autorizada aparecem. Reabrir/actualizar não cria pagamento nem apresenta verificação bancária. Valores completos não surgem em artefactos de prova ou logs.
|
||||
7. Mudança de cadastros/rascunho/instruções detectável segundo a política aprovada gera sinalização de desactualização e actualização explícita; não há mistura silenciosa de versões/contextos.
|
||||
8. B1 v1/v2 mantém dados e metadados imutáveis. Campo não capturado na versão antiga não é completado a partir do presente. Consulta histórica permanece distinta da factura emitida.
|
||||
9. Acesso directo, identificadores manipulados, outra empresa do mesmo tenant, outra unidade e revogação de direitos são tratados no servidor; utilizador de consulta não edita dados ou modelo.
|
||||
10. Textos longos, linhas múltiplas e campos ausentes têm apresentação legível sem esconder aviso ou pendências. A configuração permitida varia o desenho sem limitar o conteúdo aos três exemplos ou alterar regras.
|
||||
11. Não há gravação de emissão, imposto, recebimento, afectação ou lançamento, nem promoção de confirmações B1. Código/bases aceites preservados e nenhuma publicação.
|
||||
|
||||
## Decisões para fechar antes de implementar
|
||||
|
||||
| Decisão | Proposta para revisão |
|
||||
| --- | --- |
|
||||
| Saída inicial | Pré-visualização dentro da aplicação; sem PDF, exportação ou botão de impressão |
|
||||
| Acesso ao rascunho | Reutilizar a conjunção dos direitos de consulta acima; sem concessões novas por inferência |
|
||||
| Acesso/configuração do modelo | Definir quem configura e quais opções. Se exigir direito novo, matriz e concessão confirmadas separadamente; não derivar gestão de modelo de consulta/edição de dados |
|
||||
| Configuração inicial | Um modelo base comum, configuração limitada por empresa; decidir quais blocos opcionais/rótulos podem variar e se essa edição entra já no incremento |
|
||||
| Valores mostrados | Proposta: preços exactos propostos e valor aritmético já existente com rótulo inequívoco; imposto e total definitivo não apurados. Aprovar a nomenclatura antes de desenhar |
|
||||
| Actualidade | Proposta: composição coerente ao abrir e verificação ao regressar/actualizar; aviso de mudança, sem substituir dados automaticamente. Definir tratamento de mudança durante a consulta |
|
||||
| B1 | Proposta: ligação separada para a fotografia histórica, sem reconstrução; decidir se reutiliza o desenho com rótulo histórico explícito ou mantém o detalhe actual |
|
||||
| Instruções de pagamento | Mostrar apenas as seleccionadas e aplicáveis, sem as mascarar no documento autorizado; não fotografar valores completos para relatórios/repositório |
|
||||
|
||||
Estas decisões são de produto/segurança/apresentação. Podem ser fechadas sem aprovar taxas, plano de contas ou emissão. Não se pede uma nova aceitação de identidade, A ou B1.
|
||||
|
||||
## Fontes e decisões ainda necessárias para cálculo e emissão reais
|
||||
|
||||
**Estado das fontes:** a documentação local regista que nenhuma fonte legal/fiscal foi validada nesta tarefa. A v0.9 e as entregas aceites são referências de produto; os exemplos são referências de conteúdo. Esta revisão não realizou pesquisa legislativa nem atribui vigência/conformidade a diplomas não consultados.
|
||||
|
||||
| Tema | Fonte/evidência a obter e validar | Decisão dependente |
|
||||
| --- | --- | --- |
|
||||
| Âmbito fiscal | Enquadramento efectivo da empresa, operações e período; documentação oficial aplicável e responsável competente identificado | Quais tipos/casos de emissão devem ser suportados e em que regime |
|
||||
| Identificação e conteúdo | Textos oficiais sobre elementos documentais aplicáveis, com artigo, vigência, alterações e casos especiais | Obrigatoriedade de NUIT/moradas/identificação do cliente, descrições, datas e textos por tipo de documento |
|
||||
| Impostos | Normas/tabelas oficiais aplicáveis à operação/data, fundamentos de isenção/não sujeição e validação do enquadramento | Tratamento, taxas, incidência, descontos/encargos, adiantamentos e eventuais retenções; desconhecido continua pendente |
|
||||
| Preço e cálculo | Política aprovada compatível com as fontes aplicáveis e exemplos de cálculo conferidos | Incluído/acrescido, precisão, sequência e ponto/método de arredondamento por linha/documento e explicação de diferenças |
|
||||
| Moeda e câmbio | Regras oficiais aplicáveis e política da empresa validada; fontes de câmbio só se necessárias e autorizadas | Moeda documental/funcional, fonte/data/taxa de conversão, exposição de impostos e tratamento de diferenças |
|
||||
| Emissão e identidade | Regras oficiais aplicáveis a numeração/séries, datas, emissão, rectificação e reprodução; requisitos do software/autoridade se exigíveis | Atribuição de número, unicidade, concorrência, lacunas, momento de emissão, exemplares e controlo de alterações |
|
||||
| Arquivo e representação | Requisitos aplicáveis de arquivo, conservação, autenticidade, comunicação e acesso | Formato definitivo, conteúdo legal, evidências, retenção e eventuais integrações; nenhuma presumida neste desenho |
|
||||
| Factura-recibo e recibo | Regras aplicáveis e casos operacionais aprovados, critérios de evidência do recebimento | Quando se usam, identidades, afectações, parciais, excedentes, duplicados e prevenção de dupla documentação |
|
||||
| Responsabilidade e permissões | Matriz aprovada pelo utilizador e responsabilidades fiscal/contabilística identificadas | Quem verifica, emite, documenta recebimentos, corrige ou autoriza; sem herdar esses poderes da pré-visualização |
|
||||
| Contabilidade | Política/plano de contas, reconhecimento, períodos e classificação validados por responsável competente | Quais acontecimentos geram efeitos, quando e com que regras. Desenho ou emissão não aprovam lançamentos |
|
||||
|
||||
Para cada fonte futura registar identificação oficial/URL, versão, artigo, datas de vigência, âmbito de aplicação, responsável e estado da validação. Um parecer deve ser distinguido da norma que interpreta; um exemplo impresso não substitui nenhuma dessas fontes. Este levantamento documental é uma frente de requisitos, não implementação em paralelo de fiscalidade/contabilidade.
|
||||
|
||||
O próximo prompt de execução deverá seleccionar apenas a pré-visualização aprovada. Cálculo fiscal, emissão e demais operações continuarão bloqueados até âmbito, regras, fontes e acessos próprios estarem definidos e autorizados.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Entrega B — delimitação para revisão
|
||||
|
||||
Complemento documental de 26 de Outubro de 2026: [matriz de facturação e recebimentos](BILLING_DOCUMENTS_FIELD_SPEC.md), com campos, origens, momentos, validações e fotografia histórica. B1 mantém-se aceite no checkpoint `8fcd52c`. O complemento apenas especifica informação com base em exemplos; não autoriza o restante B/C ou publicação.
|
||||
|
||||
Data: 25 de Outubro de 2026 (Africa/Johannesburg).
|
||||
|
||||
Estado: proposta de âmbito; não autoriza implementação, migrações, novas concessões, emissão fiscal ou publicação. Base: SERVICES_WORKFLOW_SPEC.md e entrega A aceite em SERVICES_A_DELIVERY.md, checkpoint local `ca13acb`, antecedido por `26b80df`. A permanece encerrada e não é reaberta nesta revisão. Este documento não constitui validação jurídica, fiscal ou contabilística.
|
||||
|
||||
@@ -4,6 +4,8 @@ Data: 25 de Outubro de 2026.
|
||||
|
||||
Referência: SGF_TEMPO_Roteiro_v0_9.docx, particularmente secções 5, 14 a 19 e 20 a 24. Este documento especifica um percurso para revisão; não autoriza emissão fiscal, operações reais, movimentação de fundos ou implementação integral.
|
||||
|
||||
Revisão documental de 26 de Outubro de 2026: [BILLING_DOCUMENTS_FIELD_SPEC.md](BILLING_DOCUMENTS_FIELD_SPEC.md) contém a matriz de informação para factura, recibo e factura-recibo, baseada nos três exemplos fornecidos. Distingue conteúdo pretendido, propostas técnicas e requisitos legais ainda por validar. O recibo Tempo refere FT 10/2026, não FT 11/2026. Preserva B1 aceite em `8fcd52c`; esta revisão não autoriza implementação ou emissão.
|
||||
|
||||
## Decisões já tomadas
|
||||
|
||||
- Começar pelo sector de prestação de serviços, com um serviço pontual como cenário de desenvolvimento.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Primeiro cálculo de serviço — entrega local delimitada
|
||||
|
||||
26/10/2026. Preservadas identidade, A, B1 (`8fcd52c`), preparação documental, pré-visualização e alterações posteriores existentes. Não constitui aceitação pelo utilizador nem autorização de emissão.
|
||||
|
||||
## Comportamento entregue
|
||||
|
||||
Uma linha, quantidade positiva, preço não negativo, moeda explícita e modo imposto acrescido (`added`). Aritmética Brick\Math já existente, sem dependências novas nem alterações de versões. Base e IVA exactos sem float ou truncagem intermédia. A política **`SGF-SERVICE-NET-VAT-TRUNCATE2-v1`** trunca apenas o IVA documental proposto a duas casas, conserva a diferença e calcula total proposto apenas quando a base é exactamente representável a duas casas. Caso contrário o total fica pendente, sem ajustar ou truncar a base.
|
||||
|
||||
Truncagem: decisão operacional expressa do utilizador, **não obrigação legal comprovada**. Fontes reutilizadas: ficha InvesteMoz `IVA-GERAL-BASE-LIQUIDA` v1.2, cadeia da Lei 22/2022 republicada/art. 17(1), CIVA art. 15 e acontecimentos arts. 7/8/17(2). A matriz e limites constam de [SERVICE_INVOICE_CALCULATION_SPEC.md](SERVICE_INVOICE_CALCULATION_SPEC.md). Não se repetiu investigação nem se copiou código do portal.
|
||||
|
||||
Separação confirmada: a classificação contabilística orienta contas; o enquadramento fiscal da operação determina tratamento e taxa de IVA. Nenhuma taxa é inferida da conta/classificação contabilística ou aplicada universalmente a serviços. Os 16% são exclusivamente parâmetro identificado dos ensaios deste incremento. Associações contabilísticas e lançamentos não foram implementados.
|
||||
|
||||
Perfis e resultados são imutáveis. Cada resultado conserva entradas, moeda, base exacta, IVA exacto, IVA proposto, diferença, total/pendência, política, perfil/versão/fontes e versões de rascunho, preparação e origem. Mudanças tornam o resultado anterior desactualizado na nova consulta; o histórico mantém os valores originais. A leitura não recalcula nem grava. A pré-visualização já aberta conserva a versão consultada e avisa de alterações pelo mecanismo existente, exigindo actualização explícita.
|
||||
|
||||
Execução por `POST /api/service-calculations/{id}` com contexto validado, sessão, CSRF e versões de rascunho/preparação/perfil. Sem campos arbitrários de taxa ou resultados enviados pelo navegador. Bloqueio fora de `testing`/`browser-testing`, seguido da guarda do destino efectivo `sgf_tempo_test`. O ensaio requer consulta e edição de rascunho já concedidas à identidade sintética; consulta não permite executar. Configurar aparência não dá esse acesso. Nenhuma concessão nova foi aplicada às contas reais e não existe via de activação de perfil real neste incremento.
|
||||
|
||||
Bloqueio transaccional da empresa serializa comandos com as alterações existentes. Revalida contexto/permissões, rejeita versões antigas e origem/preparação por rever, e garante uma única inserção por entrada/versionamento, com repetição devolvendo o mesmo resultado. Auditoria da criação na mesma transacção. Chaves compostas e trigger de inserção recusam associações cruzadas; triggers append-only impedem actualizações/transferências em massa e apagamentos de perfis/resultados. Não se apresenta isto como prova de RLS.
|
||||
|
||||
## Migração e preservação
|
||||
|
||||
Delta previamente apresentado: `2026_10_26_030000_create_service_calculations.php` cria `service_calculation_profiles`, `service_calculation_results`, índices, relações e triggers. Não altera B1, cadastros, permissões ou dados existentes; não tem rollback destrutivo automático.
|
||||
|
||||
Aplicada primeiro em `sgf_tempo_test` nas provas; depois em **`sgf_tempo_dev`**, por `scripts/sgf.ps1 migrate`, com proprietário separado. Antes de aplicar em desenvolvimento, o estado das migrações confirmou que apenas esta estava pendente. Verificação posterior: contagens e hashes do conteúdo das 14 tabelas existentes comparadas ficaram idênticos. Tabelas novas em desenvolvimento: **zero perfis, zero resultados**. [Prova de preservação](proofs/calculation-dev-preservation.json). Nenhum reset/fresh em desenvolvimento foi aplicado por este incremento.
|
||||
|
||||
## Provas
|
||||
|
||||
Regressões dirigidas: **53 testes, 743 asserções**, todos passados, abrangendo `ServiceCalculationTest`, `DocumentPreviewTest`, `InternalConfirmationTest`, `DocumentPreparationTest` e `DatabaseSafetyTest`. Execução exclusivamente em `sgf_tempo_test`. Compilação `npm run build` passou (Vite 8.3.5). Verificação de whitespace passou; nenhuma versão de ferramenta foi alterada.
|
||||
|
||||
Cobertura nova: três casos pedidos, zero explícito, fronteiras imediatamente abaixo/na/acima da truncagem, base com fracções adicionais, perfil/taxa/moeda ausentes ou incompatíveis, modo incluído recusado, repetição, versões antigas de rascunho/perfil, histórico desactualizado, leitor sem edição, revogação no pedido seguinte, unidade de perfil alheia, empresa irmã, tentativa de transferência em massa, bloqueio no ambiente normal e fotografia B1 preservada. Guardas existentes continuam a recusar desenvolvimento e sobreposição por URL e a verificar privilégios do papel de testes.
|
||||
|
||||
Concorrência separada, em dois processos PHP realmente sobrepostos: primeiro mantém bloqueio enquanto segundo entra; um resultado criado e um replay com o mesmo ID, sem duplicação. [Prova concorrente](proofs/calculation-concurrency.json).
|
||||
|
||||
Navegador Edge, **login real sintético**, com CSRF/sessão activos em `http://127.0.0.1:8001`, base `sgf_tempo_test`, sessões/cache isolados. Não se usou `actingAs` como demonstração de login. Taxa 16% apenas no perfil identificado `ENSAIO-MZN-16 v1`, moeda MZN explicitamente indicada:
|
||||
|
||||
| Base | IVA exacto | IVA truncado | Diferença | Total proposto | Prova |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 100 | 16 | 16,00 | 0 | 116,00 | [Imagem](proofs/calculation-100.png) |
|
||||
| 1000,03 | 160,0048 | 160,00 | 0,0048 | 1160,03 | [Imagem](proofs/calculation-1000.png) |
|
||||
| 0,03 | 0,0048 | 0,00 | 0,0048 | 0,03 | [Imagem](proofs/calculation-small.png) |
|
||||
| 0,0001 | 0,000016 | 0,00 | 0,000016 | Pendente | [Imagem](proofs/calculation-base-pending.png) |
|
||||
|
||||
Repetição do segundo caso no navegador conservou o mesmo resultado. Em todos: aviso permanente **RASCUNHO — SEM VALIDADE FISCAL**, identificação de ensaio, diferença preservada e pendências visíveis. [Registo da observação](proofs/calculation-browser-results.json). Separador decimal dos valores na interface é actualmente ponto; os valores são strings exactas, sem conversão de apresentação para float.
|
||||
|
||||
### Percurso exacto e repetição local da prova
|
||||
|
||||
1. Entrar como identidade sintética existente da fixture, com a credencial local criada aleatoriamente; nunca publicar a credencial. Seleccionar **Empresa Horizonte — Demonstração → Unidade Central — Demonstração**.
|
||||
2. **Rascunhos de facturação → Ensaio base 100 → Pré-visualizar rascunho → Calcular ensaio · ENSAIO-MZN-16 v1**. Consultar os quatro valores e o total proposto.
|
||||
3. **Voltar ao registo**, seleccionar os casos **Ensaio base 1000.03**, **Ensaio base 0.03** e **Ensaio base 0.0001**, repetir pré-visualização/cálculo. O segundo caso também foi calculado pela prova concorrente e a execução no navegador reutilizou o mesmo resultado.
|
||||
4. Sair da sessão. A instância de prova é encerrada após a entrega; a aplicação normal não muda de ligação.
|
||||
|
||||
Para preparar outra prova, parar primeiro a instância com `scripts/browser-proof.ps1 stop`; executar apenas os testes dirigidos abaixo (recriam a base de testes, nunca desenvolvimento); arrancar novamente `scripts/browser-proof.ps1 start` e preparar os quatro rascunhos por `sgf.ps1 php scripts/calculation-fixture.php --env=testing`. O preparador de fixtures recusa uma base não vazia; não apaga dados para conseguir prosseguir. Depois, `sgf.ps1 php scripts/calculation-concurrency.php --env=testing` demonstra a concorrência antes do percurso no navegador. Não repetir o preparador de cálculo sobre a mesma fixture.
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test --stop-on-failure --filter="ServiceCalculationTest|DocumentPreviewTest|InternalConfirmationTest|DocumentPreparationTest|DatabaseSafetyTest"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
|
||||
```
|
||||
|
||||
Os scripts PowerShell existentes podem precisar de `-ExecutionPolicy Bypass` apenas nesse processo; não se alterou política global. A execução recusa portas ocupadas, sem terminar processos de outro projecto.
|
||||
|
||||
## Ficheiros deste incremento
|
||||
|
||||
- Núcleo novo: `app/Modules/Services/ServiceCalculation.php`; migração nova acima; `tests/Feature/ServiceCalculationTest.php`.
|
||||
- Integração: `DocumentPreview.php`, `Http/DocumentPreviewController.php`, `routes/web.php`, `resources/js/DocumentPreview.jsx`.
|
||||
- Provas: `scripts/calculation-fixture.php`, `calculation-worker.php`, `calculation-concurrency.php`, `calculation-preservation.php` e ficheiros `docs/proofs/calculation-*`.
|
||||
- Documentação: esta entrega, especificação de cálculo, README e AGENTS. Restantes alterações preexistentes foram preservadas; nenhum commit/checkpoint foi criado sem pedido.
|
||||
|
||||
## Condições para activação real
|
||||
|
||||
Continua por demonstrar a aplicabilidade concreta: regime, serviço/localização, tratamento, base tributável e acontecimento/exigibilidade. Activação requer incremento próprio com perfil real fundamentado/versionado e direitos fiscais especificamente aprovados, sem herdar poderes visuais. A decisão operacional de truncagem não comprova conformidade fiscal do montante documental. Representação de base/total com fracções adicionais fica pendente. Incluído e demais casos continuam excluídos. Não há número/data de emissão fiscal, emissão, PDF, recebimentos ou lançamentos; nenhum documento foi publicado.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Primeiro cálculo de factura de serviço — especificação e incremento delimitado
|
||||
|
||||
Data da revisão e consulta das fontes: 26 de Outubro de 2026 (Africa/Johannesburg).
|
||||
|
||||
**Actualização posterior autorizada pelo utilizador:** implementação delimitada do cálculo sobre preço sem imposto, com provas sintéticas isoladas. Não autoriza configuração fiscal real, novas concessões ou operações fictícias da Tempo Lda. Preservar preparação documental, pré-visualização, B1 e checkpoints `8fcd52c`, `ca13acb`, `26b80df`, incluindo alterações posteriores. Não reabrir aceitações.
|
||||
|
||||
## Política operacional aprovada para este incremento
|
||||
|
||||
**Separação expressamente confirmada pelo utilizador:** classificação contabilística da mercadoria/serviço orienta as contas; enquadramento fiscal da operação determina o tratamento de IVA e a taxa aplicável, incluindo taxas diferentes em serviços abrangidos. São associações distintas. Não deduzir a taxa da conta contabilística, da classificação contabilística ou do simples facto de se tratar de serviço; não presumir 16% para tudo. Este incremento não implementa contas/lançamentos nem automatiza a classificação fiscal. A taxa de ensaio provém exclusivamente do perfil sintético identificado.
|
||||
|
||||
Versão imutável **`SGF-SERVICE-NET-VAT-TRUNCATE2-v1`**. Esta orientação posterior prevalece sobre as propostas anteriores de âmbito/reconciliação desta especificação. A matriz abaixo continua a distinguir conhecimento fiscal documentado de política de produto.
|
||||
|
||||
- Uma linha, quantidade positiva, preço não negativo, moeda explícita e modo `added`. Incluído, múltiplas linhas, negativos, descontos, retenções, câmbio, adiantamentos e correcções ficam excluídos.
|
||||
- `B = q × p`; `IVA_exacto = B × taxa_percent × 0,01`, em aritmética exacta, sem float ou truncagem intermédia.
|
||||
- `IVA_documental_proposto = truncate(IVA_exacto, 2)` para valores não negativos; `diferença_descartada = IVA_exacto − IVA_documental_proposto`. Todas as grandezas ficam preservadas, incluindo quando o IVA proposto é `0,00`.
|
||||
- Se B for exactamente representável a duas casas, `total_documental_proposto = B + IVA_documental_proposto`, apresentado a duas casas. Caso contrário, conservar B, IVA exacto, IVA truncado e diferença; total proposto fica nulo e a representação da base/total pendente. Não truncar B nem criar ajustes.
|
||||
- A truncagem é **decisão operacional expressa do utilizador**, não obrigação legal comprovada. Não altera a regra exacta reutilizada do portal nem fotografias B1. HALF_UP/HALF_EVEN permanecem apenas ensaios históricos de comparação.
|
||||
- Perfil de ensaio identificado por unidade, nome e versão imutável; definição conserva moeda, taxa, natureza sintética, política e fontes. Resultados append-only conservam entradas, versões do rascunho/preparação/origem/perfil/política e valores derivados. A nova consulta marca versões antigas desactualizadas, sem recalcular ou apagar o histórico.
|
||||
- A API de execução só funciona em `testing`/`browser-testing` **e** após confirmação do destino efectivo por `TestingDatabaseGuard` (`sgf_tempo_test`, papel próprio e cluster isolado). Usa consulta/edição de rascunho já existentes apenas no ensaio. Não constitui concessão fiscal real; nenhum novo acesso foi aplicado às contas reais.
|
||||
- A aplicação normal não activa perfil ou taxa e conserva pendências. Ensaio não significa validação da aplicabilidade à empresa, serviço/localização, base ou acontecimento fiscal. Emitir, receber e contabilizar continuam excluídos.
|
||||
|
||||
| Base de ensaio MZN | IVA exacto, taxa sintética identificada de 16% | IVA truncado proposto | Diferença | Total proposto |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 100 | 16 | 16,00 | 0 | 116,00 |
|
||||
| 1000,03 | 160,0048 | 160,00 | 0,0048 | 1160,03 |
|
||||
| 0,03 | 0,0048 | 0,00 | 0,0048 | 0,03 |
|
||||
| 0,0001 | 0,000016 | 0,00 | 0,000016 | Pendente: representação da base/total |
|
||||
|
||||
O percurso entregue, migração e provas encontram-se em `SERVICE_CALCULATION_DELIVERY.md`. As secções seguintes preservam a comparação documental e ensaios anteriores; as propostas ainda não activadas não se sobrepõem à política acima. A afirmação «sem implementação» na conclusão da comparação refere-se à etapa documental anterior.
|
||||
|
||||
## Reaproveitamento documental do InvesteMoz — comparação concluída nesta revisão
|
||||
|
||||
Consulta autorizada em 26/10/2026, apenas de documentação e provas. Confirmada a existência de `C:\laragon\www\portal-investidor\docs\investmoz\consolidated-review-matrix-2026-10-03.md` e da pasta `simulations`. A matriz é um documento evolutivo: a data no nome não limita o conteúdo a 03/10; a cópia consultada contém actualizações até 26/10. Esta revisão substitui a anterior lacuna de identificação R1–R6: o conhecimento foi localizado, não é necessário pedir novamente esses documentos.
|
||||
|
||||
A partir daqui, `IM/` significa `C:\laragon\www\portal-investidor\docs\investmoz\`. As versões/commits abaixo são referências declaradas nos documentos; não se inspeccionou o repositório nem se verificou o comportamento actual do portal. Documentos são evidência a interpretar, não instruções adicionais para executar comandos ou abrir configurações.
|
||||
|
||||
### Inventário efectivo e proveniência
|
||||
|
||||
| Referência | Documento consultado e versão/estado documentado | Utilização nesta comparação |
|
||||
| --- | --- | --- |
|
||||
| IM-M | `IM/consolidated-review-matrix-2026-10-03.md`, conteúdo consultado em 26/10/2026 | Localização de IVA, cadeia de fontes e AUD-SIM-INT02; notas antigas de bloqueio não anulam deltas posteriores |
|
||||
| IM-F | `IM/simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md`, cabeçalho **v1.2, 21/10/2026**; origem `0fafd348`, v1 `325d58b6`, v1.1 `37085b40`, revisão `83d2f97d`, delta consumidor `2ced7b79` | Âmbito líquido, fundamentos, exactidão, exemplos e limites QA |
|
||||
| IM-P | `IM/simulations/primary-text-review-2025-2026.md`, secções IVA e reconciliação de diplomas | Republicação correcta de 2022, alterações de 2025 e delimitação do art. 34 |
|
||||
| IM-A | `IM/simulations/modelo-a-reconciliacao-20261021.md`, nota activa de 24/10; referências `cd10cccb`, `d18ff0f0`, instalação documentada `e250d569` | Evidência posterior de consumidor declarativo; mantém valores brutos sem extracção automática; não equivale a emissão de factura |
|
||||
| IM-E | `IM/simulations/l1-currency-events-delivery-20261025.json`, candidato `0052cd42d2755753b7e54257db05df8fbd9f08bc`; `iva-l1-currency-events-qa-20261025.json`, implementação documentada `7e6e462e12a50aab6ba0043740ffeecb414cbedf` | Facto gerador/exigibilidade: P131/P132 completos no recorte, P104 cambial parcial; L1 continua aberto |
|
||||
| IM-D | `IM/simulations/data-contracts.md` e referências de representação na matriz | Separar simulação financeira e formulários IRPC de regras de factura IVA |
|
||||
|
||||
Foram também consultadas referências documentais dos recibos de continuação integrada. Não se abriram os ficheiros de código, configurações ou saídas privadas que esses documentos mencionam. Não se executaram os testes neles descritos.
|
||||
|
||||
### Matriz de regras, provas e adequação ao SGF
|
||||
|
||||
| Regra | Origem/versão | Fonte, vigência e condições | Provas existentes documentadas | Classificação e reaproveitamento | Pendência efectiva para SGF |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Base tributável | IM-F v1.2, IM-P | CIVA republicado pela Lei 13/2016, art. 15(1), especiais 15(2), inclusões 15(5), exclusões condicionais 15(6), PDF 13–14; início 01/01/2017, alterações confrontadas com 2022/2025 | Base 100 → imposto 16; base 105 após componentes condicionais → 16,8 | **Fundamentada, adaptação de caso pendente.** Reutilizar contraprestação e condições; orçamento, rótulo de serviço ou `q×p` não provam base legal | Confirmar que o serviço pontual SGF corresponde à base líquida proposta, sem componentes especiais |
|
||||
| Taxa geral e vigência | IM-F/IM-P; decisão de proveniência documentada em 08/10 | Lei 22/2022 **republicada**, art. 17(1), PDF 3, BR 2252-(27); art. 6 da lei, PDF 4: 01/01/2023. Lei 10/2025, vigente desde 01/01/2026, não inclui arts. 15/17 na lista de alterações | Perfil QA `IVA-GERAL-BASE-LIQUIDA`: 16 percent, moeda MZN, contexto declarado `internal_general_net`, cobertura técnica 2026 | **Regra geral fundamentada; simulação demonstrada.** Reutilizar cadeia documental e taxa exacta sob o âmbito aplicável, não as imagens como fonte | Confirmar regime, incidência, localização e tratamento da operação/empresa; não presumir taxa geral para toda operação |
|
||||
| Âmbito do serviço | IM-F | CIVA arts. 1(1)a, 2(1)a, 4(1), 6(3) e excepções; não generalizar art. 6(1), relativo a bens, aos serviços | Exclusões expressas: isenções, taxas reduzidas, importações, digital/não residentes, dedução/reembolso | **Fundamentada com classificação pendente.** Reutilizar perguntas e fronteiras, não classificação automática pelo perfil do cliente | Identificar serviço e localização concretos; dimensão, nome e NUIT não demonstram regime |
|
||||
| Data fiscal | IM-F + IM-E de 25/10 | CIVA art. 17(2); facto gerador art. 7, PDF 8; exigibilidade art. 8, PDF 9. Cadeia 2022/2025 documentada sem alteração destes artigos | IM-E: 9 ramos P131 e 5 tratamentos P132; serviço concluído e cobrança antecipada distinguidos; factura pontual/tardia/ausente, pagamentos e resto pendente. 19 testes frontend, 1 teste API/65 asserções, prova Edge de recuperação, reportados no recibo | **Fundamentada e ensaiada no recorte.** Reutilizar distinção acontecimento/exigibilidade e casos, não data da consulta, vencimento ou B1 como substituto | Aplicar ramo ao serviço SGF e identificar os dados que o comprovam; não implementar todos os 14 ramos neste primeiro incremento |
|
||||
| Precisão | IM-F v1.2 e provas QA | Decimal/racional exacto; taxa em percent distinta de fracção; `not_applied`, sem arredondamento automático | `0,03×16%=0,0048`; `9007199254740993,0003×16%=1441151880758558,880048`; zero explícito distinto de ausente | **Orientação técnica já estabelecida e demonstrada.** Reutilizar os casos e representação exacta, sem copiar código nem reabrir este princípio | Limites técnicos do SGF e tratamento de capacidade; o caso grande é prova do portal e não alarga os limites de entrada de A |
|
||||
| Arredondamento do cálculo líquido | IM-F v1.2 | Nenhuma quantização é aplicada nesse percurso; duas casas monetárias não obrigam a arredondar o resultado exacto | `0,0048` preservado, não convertido em `0,00`; persistência/relatório conservam resultado | **Reutilizável no mesmo âmbito.** Retirar a ausência de política genérica como bloqueio ao cálculo exacto líquido; não escolher HALF_UP/HALF_EVEN | Uma exigência futura de formato/consumidor requer fundamento próprio, sem alterar o valor exacto |
|
||||
| Preço com imposto incluído | IM-F/IM-P/IM-A | CIVA art. 34, Lei 10/2025, PDF 14: extracção com divisor 116 e arredondamento à unidade mais próxima **nesse contexto** | O percurso líquido exclui extracção; Modelo A preserva bruto sem calcular a base; não foi demonstrada emissão SGF | **Regra identificada, adequação pendente.** Reutilizar fonte e delimitação, não transpor genericamente o arredondamento para toda factura | Conferir âmbito do art. 34 no consumidor de factura pretendido, unidade, sequência e empates aplicáveis; identidade algébrica não resolve isso |
|
||||
| Apresentação e reconciliação | IM-F, IM-A, IM-D e matriz | CIVA art. 27(5)c/d distingue preço líquido/base, taxa e imposto. RIVA art. 9(1)b do exemplar de 2008 trata cheque/vale, não arredondamento universal. M20/M22 são representações IRPC | QA preserva valor/moeda/contexto e fotografia; exemplos artificiais de centésimos mostram conflitos, não normas | **Princípios reutilizáveis; adaptações declarativas não aplicáveis à factura.** Separar cálculo, apresentação e emissão | Determinar apenas a representação exigida para o consumidor futuro; não arredondar para imitar formulário fiscal |
|
||||
| Historial e limites das provas | IM-F revisões e IM-E | Regra/entradas/contexto versionados, relatório preservado; simulação sobre enquadramento declarado | IM-F: 6 testes/86 asserções de governação; delta 8 testes/75 asserções e 2 frontend; rota QA 1 teste/17 asserções; exemplo Edge 1000,03 → 160,0048 recuperado com hash igual | **Evidência técnica reportada, não homologação jurídica.** Reutilizar requisitos de rastreabilidade e casos sintéticos, preservando B1 do SGF | Não transferir papéis, bypass RLS de QA, credenciais, contas ou estado de activação do portal. Testes de política não provam isolamento RLS |
|
||||
|
||||
A aceitação documental de uma fonte no portal não é parecer jurídico nem aprovação automática para emitir no SGF. As provas acima são as reportadas nas versões identificadas, não uma nova execução nem conferência independente do estado actual. A cobertura QA de 01/01–31/12/2026 não é caducidade legal da taxa em 31/12/2026. O trabalho recente de IRPC e os formatos M20/M22 não constituem fundamento de arredondamento IVA.
|
||||
|
||||
### Exemplares e integridade
|
||||
|
||||
Verificados por leitura os SHA-256 das cópias em `IM/simulations/source-archive/`; coincidem com os registados na ficha. O cotejo de artigos/páginas acima é reaproveitado da leitura documental do portal: não se apresenta como nova leitura jurídica integral dos PDFs nesta revisão.
|
||||
|
||||
| Exemplar | SHA-256 confirmado |
|
||||
| --- | --- |
|
||||
| `Lei-13-2016-CIVA-BR-156-10-suplemento.pdf` | `B90DC7C14ECEA2E378C4A62E8E5A47D54A5644C457BAEB8B8053B92FD2A5710B` |
|
||||
| `Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf` | `EA1B0DC6F5CF6E595F6D9A19E7042F398999E0087CAEF1785F6E8FAC571B5F53` |
|
||||
| `BR-248-I-Suplemento-1-2025.pdf` | `CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251` |
|
||||
|
||||
Não houve nova pesquisa na Internet: as fontes e provas disponíveis permitem reduzir as lacunas antes de qualquer pesquisa adicional.
|
||||
|
||||
## 1. Caso mínimo proposto
|
||||
|
||||
Um serviço pontual, **uma linha**, uma moeda documental explicitamente escolhida, uma quantidade positiva, preço não negativo, um tratamento tributado e uma taxa positiva aplicável à data fiscal de referência aprovada. A especificação contempla os dois modos existentes: imposto incluído e acrescido. A proposta pequena da secção 8 começa pelo acrescido sobre base líquida; incluído permanece pendente até à adequação específica do art. 34, sem eliminar o modo existente. «Por definir» mantém pendência.
|
||||
|
||||
Sem descontos, adiantamentos, retenções, encargos adicionais, câmbio, múltiplas taxas, facturação parcial, correcções ou cálculos de recebimentos. Isenção, não sujeição e taxa zero não serão representadas como uma taxa positiva em falta: precisam de tratamento/fundamento próprios e ficam fora desta primeira implementação proposta. O enquadramento real ainda não está aprovado.
|
||||
|
||||
Uma linha reduz o primeiro incremento, mas não dispensa escolher e registar uma política por linha ou por documento. Nessa situação os resultados coincidem; não inferir daí equivalência quando se acrescentarem linhas. O exemplo de três linhas abaixo serve só para fechar essa decisão e impedir uma expansão silenciosa.
|
||||
|
||||
Continuar a mostrar **RASCUNHO — SEM VALIDADE FISCAL**. Um cálculo matematicamente completo não cria número fiscal, emissão, recebimento ou lançamento, nem altera a confirmação interna B1.
|
||||
|
||||
## 2. O que está confirmado e o que não está
|
||||
|
||||
| Estado | Conteúdo |
|
||||
| --- | --- |
|
||||
| Base existente a preservar | Quantidades/preços como cadeias decimais, até 16 algarismos inteiros/8 decimais; aritmética exacta; modos por definir/incluído/acrescido; moeda não presumida; contexto, permissões, versões e fotografias imutáveis |
|
||||
| Decisão expressa para esta especificação | Caso simples, distinção entre cálculo/arredondamento/apresentação, exemplos sintéticos, bloqueio por regras em falta e separação de numeração/emissão |
|
||||
| Proposta técnica a aprovar | Uma linha e uma taxa positiva; cálculo racional exacto; percurso líquido sem arredondamento; ensaios de quantização separados do perfil real; cálculo derivado de rascunho sem actualização retroactiva |
|
||||
| Informação em falta | Regime da empresa, natureza/localização da operação, tratamento, taxa/vigência aplicáveis, data fiscal de referência, moeda e responsável pela validação; regra específica de extracção/apresentação apenas quando o respectivo consumidor a exigir |
|
||||
| Não aprovado | Aplicar automaticamente 16% à Tempo Lda ou a qualquer serviço; presumir isenção/taxa zero; considerar verificada fiscalmente uma fórmula só porque fecha aritmeticamente |
|
||||
|
||||
## 3. Fontes oficiais: reconciliação com a documentação do portal
|
||||
|
||||
A pesquisa pública anterior encontrou a primeira publicação de 2022. **Correcção desta revisão:** não a usar como exemplar normativo; IM-P identifica que foi dada sem efeito e substituída pela republicação no **2.º suplemento do BR 250**, de 28/12/2022, 18 páginas. A cópia arquivada e o hash acima substituem aquela referência como fundamento. Não é necessário voltar a pedir o articulado de 2025: está identificado e cotejado em IM-P/IM-F.
|
||||
|
||||
| Fonte oficial / exemplar reutilizado | Regra documentada e vigência | Limite / acção restante |
|
||||
| --- | --- | --- |
|
||||
| [INM — Lei 13/2016, republicação CIVA](https://www.inm.gov.mz/pt-br/content/suplemento-n%C2%BA-10-de-301216-pag-1426-259-290-br-n%C2%BA-156-boletim-da-rep%C3%BAblica-i-serie), arquivo identificado acima | Arts. 1/2/4/6 incidência e serviços; 7/8 acontecimentos; 15 base; 17(2) taxa na exigibilidade; 27(5)c/d componentes documentais. Início 01/01/2017, ler com alterações posteriores | Aplicabilidade à operação concreta, não nova descoberta dos artigos |
|
||||
| [INM — Lei 22/2022 republicada, suplemento 2](https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da), arquivo identificado acima | Art. 17(1): 16%; art. 6 da lei: início 01/01/2023 | Taxa geral fundamentada; não configura automaticamente a Tempo Lda |
|
||||
| BR 248, suplemento 1, 29/12/2025, exemplar arquivado acima; IM-P/IM-F | Lei 10/2025, PDF 12–15, início 01/01/2026; art. 34 PDF 14 sobre extracção; arts. 15/17 não incluídos na lista de alterações | Confirmar adequação do art. 34 ao consumidor incluído pretendido; não presumir vigência única de todas as leis do mesmo boletim |
|
||||
| Decreto 7/2008, `BR-16-I-Serie-2008-04-16.pdf`, referenciado em IM-F | RIVA arts. 4/5: base/operação versus imposto devido após deduções; art. 9(1)b: meio de pagamento por cheque/vale, exemplar histórico | Não transpor esse arredondamento para cálculo líquido ou para toda factura |
|
||||
| Comunicação governamental de 30/09/2026, já registada em IM-F | Anúncio de alterações regulamentares; articulado publicado/eficácia por disposição não demonstrados nessa evidência | Pesquisar só quando uma disposição puder afectar o consumidor delimitado; não bloquear genericamente o cálculo líquido exacto |
|
||||
|
||||
As rectificações de 2017 e a referência a Decreto 8/2017/BR 50 de 30/03/2017 estão já sinalizadas no dossier; não repetir a busca no BR 54 incorrectamente associado. Correspondência de disposição e consolidação regulamentar permanecem pontuais quando relevantes, não prova de inexistência de conhecimento. Não se afirma consolidação integral de toda a legislação até hoje.
|
||||
|
||||
## 4. Entradas e perfil de cálculo proposto
|
||||
|
||||
Reutilizar IDs/versões de empresa, unidade, cliente, prestação e rascunho, linhas/preparação e moeda. Não duplicar cadastros ou alterar preços guardados. O perfil de regra, se futuramente aprovado, identifica:
|
||||
|
||||
- Regime e tratamento da operação, com prova de aplicabilidade.
|
||||
- Taxa exacta e unidade explícita: percentagem e fracção são distintas; 10% corresponde exactamente a 0,10, sem float.
|
||||
- Data fiscal de referência, regra que a determina e intervalo de vigência sem sobreposição/ambiguidade.
|
||||
- Moeda e modo incluído/acrescido. Para o percurso líquido, arredondamento `not_applied` e reconciliação exacta `B* + I* = T*`. Escala/pontos/método de quantização só para o consumidor específico fundamentado.
|
||||
- Identidade/versão do perfil, fontes, responsável/estado de validação e âmbito da empresa/operação.
|
||||
|
||||
Os exemplos usam **UT, unidade monetária fictícia de ensaio**, não um código ISO nem a moeda da Tempo Lda. Duas casas e taxas de 10%/20% são parâmetros artificiais do ensaio, não configuração real. Uma taxa de 16% observada nas imagens não activa qualquer perfil.
|
||||
|
||||
## 5. Fórmulas exactas, anteriores a qualquer arredondamento
|
||||
|
||||
Definir `q` quantidade, `p` preço unitário proposto e `r` taxa em fracção exacta. `B*`, `I*`, `T*` são valores matemáticos anteriores à quantização, não montantes emitidos.
|
||||
|
||||
| Modo | Fórmulas |
|
||||
| --- | --- |
|
||||
| Imposto acrescido | `B* = q × p`; `I* = B* × r`; `T* = B* + I*` |
|
||||
| Imposto incluído | `T* = q × p`; `B* = T* / (1 + r)`; `I* = T* − B*` (equivalente a `T* × r / (1 + r)`) |
|
||||
| Por definir / tratamento desconhecido | Não escolher fórmula fiscal. Conservar apenas a aritmética provisória já existente e pendências |
|
||||
|
||||
Estas identidades matemáticas não provam que `q × p` seja a base legal da operação. Só se usam quando o perfil fiscal aprovado confirmar que esse caso simples corresponde ao valor relevante, sem componentes adicionais. Um preço incluído desconhecido não é «base sem imposto».
|
||||
|
||||
### Precisão de cálculo
|
||||
|
||||
Princípio existente, reforçado pelas provas do portal: multiplicação decimal exacta e **fracções racionais exactas** quando a divisão não terminar, reutilizando Brick\Math já instalado, sem novas dependências. Não truncar uma divisão a 8/12 casas e voltar a usá-la como valor exacto. Os limites de entrada de A não são uma regra de arredondamento monetário; produtos/divisões podem exigir mais casas.
|
||||
|
||||
Conservar entradas canónicas e, se necessário para rastreio, numerador/denominador dos resultados exactos. Decimal de uma fracção periódica mostrado ao utilizador deve ser marcado como aproximação e nunca realimentar o cálculo. Definir limites técnicos de tamanho/execução antes da implementação, sem transformar um erro de capacidade em arredondamento silencioso.
|
||||
|
||||
### Arredondamento / quantização
|
||||
|
||||
Definir explicitamente `Q(s,m,x)`: escala monetária `s`, método `m`, valor exacto `x`. Aplicar apenas nos pontos aprovados. A apresentação com duas casas não autoriza quantizar todos os passos a duas casas.
|
||||
|
||||
Parâmetro exclusivo de ensaio, **não norma fiscal nem preferência de produto**: `s=2`, `m=HALF_UP` para valores não negativos. HALF_EVEN e políticas por linha/documento servem para demonstrar diferenças; a regra real exige fundamento fiscal aplicável, começando pela comparação com o material do portal. Não suportar valores negativos neste incremento; correcções exigem âmbito próprio.
|
||||
|
||||
### Ensaios de reconciliação para uma linha — não adoptar como perfil fiscal
|
||||
|
||||
As duas políticas seguintes ficam preservadas apenas como hipóteses de ensaio que expõem diferenças. Não são a proposta actual para o percurso líquido, que conserva B*, I* e T* exactos com `not_applied`; não se pede aprovação por preferência entre métodos:
|
||||
|
||||
- **Acrescido:** `B = Q(B*)`; `I = Q(I*)`; `T = B + I`. A diferença `T − Q(T*)`, se existir, deve ser registada/explicada; não substituir silenciosamente o total por outro cálculo.
|
||||
- **Incluído:** preservar a âncora bruta `T = Q(T*)`; calcular `I = Q(I*)`; obter `B = T − I`. Registar explicitamente `delta_base = B − Q(B*)`. Esta política de base por diferença evita inventar um desconto, mas precisa de aprovação fiscal; não a apresentar como simples arredondamento independente da base.
|
||||
|
||||
Invariantes a verificar no perfil aprovado: mesma moeda, `B + I = T`, reprodutibilidade e explicação das diferenças face aos valores exactos. Se a norma exigir outra âncora/sequência, substituir o perfil proposto por essa regra antes de implementar. Não exigir simultaneamente invariantes incompatíveis nem esconder o conflito.
|
||||
|
||||
Para múltiplas linhas, somar resultados quantizados por linha pode diferir de quantizar uma soma exacta. Se a política for por documento, falta definir a afectação dos resíduos às linhas e o critério determinístico. Isso não entra implicitamente no primeiro caso de uma linha. Nenhum «ajuste» sem identidade/regra, alteração de taxa ou conta transitória para fazer coincidir totais.
|
||||
|
||||
### Apresentação
|
||||
|
||||
Separar valores exactos, componentes quantizadas e formatação (casas/separadores/língua/moeda). Não analisar novamente texto formatado para calcular. Continuar a distinguir cálculo matemático, validação fiscal e emissão. Mesmo com perfil aprovado, nesta fase o documento é rascunho; total calculado sob perfil não significa dívida emitida ou autorização de emissão.
|
||||
|
||||
## 6. Exemplos sintéticos e resultados esperados
|
||||
|
||||
Perfil fictício `ENSAIO-UT-HU2`, não activável na Tempo Lda; escalas/taxas/âmbitos temporais abaixo são apenas dados de teste. Nenhuma inserção nas bases foi realizada.
|
||||
|
||||
| Caso | Entradas | Resultado exacto | Resultado esperado sob o perfil de ensaio |
|
||||
| --- | --- | --- | --- |
|
||||
| A — acrescido | `q=2,5`, `p=80`, `r=10%` | `B*=200`, `I*=20`, `T*=220` | `B=200,00`, `I=20,00`, `T=220,00 UT` |
|
||||
| B — incluído | `q=2`, `p=55`, `r=10%` | `T*=110`, `B*=100`, `I*=10` | `B=100,00`, `I=10,00`, `T=110,00 UT` |
|
||||
| C — empate de arredondamento, acrescido | `q=1`, `p=0,05`, `r=10%` | `B*=0,05`, `I*=0,005`, `T*=0,055` | HALF_UP: `B=0,05`, `I=0,01`, `T=0,06`. HALF_EVEN, como alternativa de comparação: `I=0,00`, `T=0,05`. Não intercambiáveis |
|
||||
| D — incluído com reconciliação | `q=1`, `p=0,03`, `r=20%` | `T*=0,03`, `B*=0,025=1/40`, `I*=0,005=1/200` | Hipótese incluído de ensaio: `T=0,03`, `I=0,01`, `B=0,02 UT`; `delta_base=-0,01`. Arredondar B* e I* independentemente daria `0,03+0,01=0,04`, incompatível com o bruto de `0,03`; não ocultar a diferença |
|
||||
| E — quantidade fraccionada e preço muito pequeno | `q=1,25`, `p=0,00000001`, acrescido, `r=10%` | `B*=0,0000000125`, `I*=0,00000000125`, `T*=0,00000001375` | À escala fictícia 2, todos quantizam para `0,00`. Preservar os positivos exactos; decidir se a política real permite ou bloqueia essa situação, sem elevar artificialmente preço/total |
|
||||
| F — preço explicitamente zero | `q=1`, `p=0`, acrescido, perfil e taxa conhecidos | `B*=I*=T*=0` | `0,00` sob regra completa; não demonstra que qualquer serviço gratuito tenha esse tratamento fiscal |
|
||||
| G — taxa/tratamento em falta, mesmo com preço zero | `q=1`, `p=0` ou `80`, sem perfil fiscal completo | Apenas a aritmética provisória é conhecida | Imposto/total fiscal não apurados; pendência e cálculo não validado. Não usar zero para suprir informação fiscal |
|
||||
|
||||
**Comparação por linha/documento, fora do caso mínimo:** três linhas de base exacta `0,05`, cada uma com taxa artificial de 10%. HALF_UP por linha: imposto `0,01 × 3 = 0,03`, base `0,15`, total `0,18`. Por documento: imposto `Q(0,015)=0,02`, total `0,17`. Diferença `0,01`; bloquear a extensão a múltiplas linhas enquanto a política e a distribuição não estiverem aprovadas.
|
||||
|
||||
**Vigência fictícia:** perfil A válido de 01/01/2030 a 30/06/2030, perfil B desde 01/07/2030, para a mesma operação artificial. Na data fiscal de referência 30/06 seleccionar A; em 01/07 seleccionar B. Data desconhecida, lacuna ou sobreposição ⇒ pendência, sem escolher «a taxa mais recente». Estas datas não representam alteração legal real.
|
||||
|
||||
**Casos recusados/pendentes adicionais:** moeda ausente ou diferente no mesmo cálculo; preço com precisão excedente; quantidade não positiva; modo por definir; regra expirada/sem fonte ou sem validação; rascunho/contexto desactualizados; discrepância de reconciliação; leitura de empresa/unidade alheia. Manter a recusa e o motivo sem gravar resultados parciais como válidos.
|
||||
|
||||
## 7. Estados e comportamento quando faltar regra
|
||||
|
||||
Proposta de distinção, sem novos estados de emissão:
|
||||
|
||||
1. **Pendente de dados/regra:** lista precisa do que falta; imposto/base fiscal/total não apurados conforme dependências. A aritmética provisória existente pode continuar visível, claramente separada.
|
||||
2. **Simulação sintética:** fórmula/perfil de ensaio explícitos e isolados em testes; nunca valida a empresa real.
|
||||
3. **Calculado sob perfil aprovado:** só depois de entradas, aplicabilidade, vigência, moeda e política completas, com resultado reconciliado. Ainda não significa documento emitido nem validação automática de todas as obrigações fiscais.
|
||||
4. **Conflito/fora do âmbito/falha técnica:** não há resultado validado; preservar entradas e última versão identificada sem a apresentar como actual. Falha técnica não é isenção, pendência resolvida ou taxa zero.
|
||||
|
||||
Sem regime, tratamento, taxa, data/vigência ou moeda aplicáveis: **não calcular imposto por omissão e não marcar validado**. No percurso de base líquida exacta, `not_applied` é explícito: a ausência de política genérica de arredondamento não impede esse cálculo. Se faltar uma regra específica exigida para extracção do incluído ou para o consumidor monetário, manter pendente esse resultado/consumidor, sem invalidar silenciosamente valores exactos já determinados. Dar origem e próxima acção: completar dados, obter fonte vigente, confirmar enquadramento ou aprovar a política. Alterar dados/regra invalida a actualidade do cálculo, sem mudar silenciosamente resultados já consultados.
|
||||
|
||||
## 8. Pequena proposta de implementação futura
|
||||
|
||||
Depois da aprovação do incremento, começar pelo percurso de base líquida, uma linha e moeda explícita, sem arredondamento automático. Acrescentar um calculador determinístico do caso de uma linha, reutilizando o rascunho e uma versão explícita de perfil. Não construir já um motor fiscal geral ou editor de taxas arbitrárias. Se faltar validação real, só poderá existir ensaio sintético isolado autorizado; não activar regras incompletas na Tempo Lda.
|
||||
|
||||
Resultado derivado proposto: IDs/versões de origem, modo, perfil/fontes/vigência, data de referência, moeda/escala, entradas exactas, resultados exactos, política/pontos de quantização, componentes reconciliadas, resíduos explícitos e estado/pendências. Definir conservação/versionamento desse resultado no delta futuro; não o inserir no JSON de uma fotografia B1 existente.
|
||||
|
||||
Integração mínima na pré-visualização: manter aviso de rascunho, mostrar o perfil e distinção entre valores exactos/calculados/por validar, sem número ou emissão. A leitura continua sem resolver pendências por si só. Os antigos rascunhos com modo desconhecido não são reinterpretados automaticamente; a activação futura deve ser explícita e versionada. Configurar apresentação não concede gerir regras fiscais ou aprovar cálculos; acessos concretos terão aprovação própria se necessários.
|
||||
|
||||
Provas a exigir futuramente no recorte aprovado: casos exactos reutilizados IM-F, casos A/F/G, moeda desconhecida, datas de vigência, perfil sem validação, versão/contexto, isolamento, reprodutibilidade, reconciliação e permanência das fotografias B1/preparação histórica. Prova visual só em instância isolada com `sgf_tempo_test`; nenhuma operação real ou fixture na Tempo Lda. Nesta etapa não foram implementados ou executados testes do sistema.
|
||||
|
||||
## 9. Decisões realmente restantes
|
||||
|
||||
**Escolhas de produto:** aprovar o primeiro incremento pequeno de cálculo exacto sobre base líquida, uma linha, mantendo incluído visível mas pendente de adequação normativa; indicar a moeda concreta (MZN é a cobertura documentada do portal, não um valor a presumir no SGF) e os actores autorizados. Se o objectivo exigir incluído já no primeiro incremento, resolver antes o ponto fiscal específico abaixo. Nenhuma concessão é inferida dos direitos de pré-visualização.
|
||||
|
||||
**Validação fiscal da operação:** identificar regime, serviço/localização e tratamento aplicáveis à empresa, confirmar que a contraprestação simples constitui a base do art. 15 e determinar o acontecimento/exigibilidade pelos arts. 7/8. A cadeia documental da taxa geral de 16% desde 2023 já foi encontrada; resta a sua aplicabilidade ao caso, não escolher de novo uma taxa por preferência.
|
||||
|
||||
**Incluído e consumidores futuros:** verificar a adequação do art. 34 à factura pretendida e, se aplicável, unidade, sequência e tratamento de empates com fundamento. Só depois definir extracção/reconciliação/apresentação. HALF_UP/HALF_EVEN e formatos M20/M22 não substituem essa validação. Procurar alterações regulamentares apenas para a disposição/consumidor que possa ser afectado, reutilizando o dossier e os exemplares já identificados.
|
||||
|
||||
Não voltam a aprovação valores exactos, ausência de truncagem silenciosa, desconhecido diferente de zero ou história preservada. Não é necessário pedir de novo R1–R6. Nenhuma regra é activada nesta revisão. Não se alteraram código, bases, permissões, fotografias ou portal; não se copiaram implementações nem se executaram testes. Numeração, emissão, PDF, recebimentos, contabilização e publicação permanecem fora do âmbito.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"database": "sgf_tempo_dev",
|
||||
"confirmed_grants_applied": 4,
|
||||
"company_id": 1,
|
||||
"proposer_id": 1,
|
||||
"validator_id": 2,
|
||||
"no_unit_grants": true,
|
||||
"prior_grants_unchanged": true,
|
||||
"business_records_and_calculation_files_unchanged": true,
|
||||
"real_classification_rows": 0
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"url": "http://127.0.0.1:8001",
|
||||
"real_session_login": true,
|
||||
"synthetic_distinct_users": true,
|
||||
"csrf_active": true,
|
||||
"observed": [
|
||||
"Preparer created and submitted category v2 with explicit replacement and start 2026-11-01.",
|
||||
"Before approval, consultation for 2026-11-01 still resolved category v1.",
|
||||
"Validator had decision controls without preparation, correction or capture controls.",
|
||||
"Validator approved v2 with reason and refused another submitted category with reason.",
|
||||
"Consultation for 2026-10-31 returned category v1; 2026-11-01 returned v2.",
|
||||
"Operation historical resolution retained category v1, service/plan/account versions and original reference date."
|
||||
],
|
||||
"new_real_grants_applied": false,
|
||||
"screenshots": [
|
||||
"accounting-validation.png",
|
||||
"accounting-history.png"
|
||||
],
|
||||
"credentials": "omitted"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"parallel_processes": true,
|
||||
"lock_overlap_observed": true,
|
||||
"statuses": [
|
||||
200,
|
||||
409
|
||||
],
|
||||
"decisions": 1
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"database": "sgf_tempo_dev",
|
||||
"existing_data_hashes_unchanged": true,
|
||||
"existing_grants_unchanged": true,
|
||||
"calculation_files_unchanged": true,
|
||||
"new_table_counts": {
|
||||
"ca_proposals": 0,
|
||||
"ca_submissions": 0,
|
||||
"ca_decisions": 0,
|
||||
"ca_resolutions": 0,
|
||||
"ca_commands": 0
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"url": "http://127.0.0.1:8001",
|
||||
"real_synthetic_login": true,
|
||||
"context": "Empresa Horizonte — Demonstração / Unidade Central — Demonstração",
|
||||
"policy": "SGF-SERVICE-NET-VAT-TRUNCATE2-v1",
|
||||
"observed": [
|
||||
{
|
||||
"base": "100",
|
||||
"vat_exact": "16",
|
||||
"vat_proposed": "16.00",
|
||||
"difference": "0",
|
||||
"total": "116.00",
|
||||
"screenshot": "calculation-100.png"
|
||||
},
|
||||
{
|
||||
"base": "1000.03",
|
||||
"vat_exact": "160.0048",
|
||||
"vat_proposed": "160.00",
|
||||
"difference": "0.0048",
|
||||
"total": "1160.03",
|
||||
"repeat_same_result": true,
|
||||
"screenshot": "calculation-1000.png"
|
||||
},
|
||||
{
|
||||
"base": "0.03",
|
||||
"vat_exact": "0.0048",
|
||||
"vat_proposed": "0.00",
|
||||
"difference": "0.0048",
|
||||
"total": "0.03",
|
||||
"screenshot": "calculation-small.png"
|
||||
},
|
||||
{
|
||||
"base": "0.0001",
|
||||
"vat_exact": "0.000016",
|
||||
"vat_proposed": "0.00",
|
||||
"difference": "0.000016",
|
||||
"total": null,
|
||||
"base_representation_pending": true,
|
||||
"screenshot": "calculation-base-pending.png"
|
||||
}
|
||||
],
|
||||
"permanent_notice_observed": true,
|
||||
"synthetic_label_observed": true,
|
||||
"credentials": "omitted"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"two_php_processes": true,
|
||||
"lock_overlap_observed": true,
|
||||
"same_result_id": true,
|
||||
"one_insert_one_replay": true,
|
||||
"result_count": 1
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"database": "sgf_tempo_dev",
|
||||
"existing_tables_compared": [
|
||||
"a_clients",
|
||||
"a_services",
|
||||
"a_service_drafts",
|
||||
"a_billing_drafts",
|
||||
"a_history",
|
||||
"a_events",
|
||||
"a_outbox",
|
||||
"a_pending",
|
||||
"b_internal_confirmations",
|
||||
"doc_records",
|
||||
"doc_history",
|
||||
"document_preview_models",
|
||||
"document_preview_model_history",
|
||||
"permission_grants"
|
||||
],
|
||||
"identical_counts_and_content_hashes": true,
|
||||
"new_profiles": 0,
|
||||
"new_results": 0
|
||||
}
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"preparation_records_by_kind": [
|
||||
{
|
||||
"kind": "billing_drafts",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"kind": "client",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"kind": "issuer",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"kind": "payment",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"kind": "service_drafts",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"preparation_history_versions": 10,
|
||||
"confirmation_snapshot_schemas": {
|
||||
"1": 1,
|
||||
"2": 1
|
||||
},
|
||||
"instruction_values_in_audit_or_technical_log": false,
|
||||
"instruction_values": "omitted",
|
||||
"receipt_table_present": false,
|
||||
"journal_table_present": false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"real_synthetic_login": true,
|
||||
"results": [
|
||||
"Client created with name only; company/client profiles reused.",
|
||||
"Source preparation and selected issuer instruction reused in billing draft.",
|
||||
"Existing B1 v1 and future v2 snapshots unchanged after configuration updates.",
|
||||
"Reader can consult instructions; editing denied 403.",
|
||||
"Old version/context denied 409; other company denied 403.",
|
||||
"Currency unknown, exact prices unchanged; no receipt, issue or accounting operation."
|
||||
],
|
||||
"instruction_values": "omitted"
|
||||
}
|
||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"real_synthetic_login": true,
|
||||
"configuration_authorized_with_synthetic_grant": true,
|
||||
"reader_configuration_denied": true,
|
||||
"stale_alert_kept_original_text": true,
|
||||
"explicit_refresh_loaded_new_text": true,
|
||||
"small_screen": {
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"notice_visible": true,
|
||||
"no_horizontal_overflow": true
|
||||
},
|
||||
"historical_view_did_not_use_current_configuration": true,
|
||||
"context_change_removed_previous_preview": true,
|
||||
"instruction_values": "omitted"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"parallel_php_processes": true,
|
||||
"repeatable_read_verified": true,
|
||||
"old_consultation": "both original versions",
|
||||
"explicit_new_consultation": "both committed new versions",
|
||||
"mixed_versions": false,
|
||||
"instruction_values": "omitted"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"database": "sgf_tempo_test",
|
||||
"real_synthetic_login": true,
|
||||
"results": [
|
||||
"Read-only composition reuses exact values and unknown currency; no-store headers.",
|
||||
"Revision changes after configuration; explicit new read loads new values.",
|
||||
"Reader consults but cannot configure (403); hidden mandatory content rejected (422).",
|
||||
"Old configuration version and context rejected (409).",
|
||||
"Historical consultation is independent of current configuration and does not reconstruct missing issuer name."
|
||||
],
|
||||
"instruction_values": "omitted"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const kinds={plan:'Plano da empresa',account:'Conta empresarial',category:'Categoria e regra-base',service_link:'Ligação do serviço à categoria',service_override:'Excepção explícita por serviço'};
|
||||
const states={draft:'Proposta em preparação',submitted:'Submetida — aguarda validação',approved:'Aprovada — verificar vigência',refused:'Recusada'};
|
||||
const labels={plan:'Plano',source_version:'Versão documental',plan_id:'Versão do plano aprovada',code:'Código da conta',name:'Designação',origin:'Origem da conta',movable:'Admite movimento após validação',evidence:'Fundamento e prova da conta empresarial',account_id:'Conta aprovada',conditions:'Condições e exclusões',service_id:'Serviço existente',category_id:'Categoria aprovada'};
|
||||
|
||||
function Decision({row,can,busy,act}) {
|
||||
const [reason,setReason]=useState('');
|
||||
if(row.state!=='submitted'||!can('catalogue_accounting.approve'))return null;
|
||||
return <form onSubmit={e=>e.preventDefault()}><label>Motivo da decisão sobre a versão {row.version}<textarea required maxLength={2000} value={reason} onChange={e=>setReason(e.target.value)} /></label><div className="actions"><button disabled={busy||!reason.trim()} onClick={()=>act(`/proposals/${row.id}/decision`,{decision:'approved',reason})}>Validar esta versão</button><button disabled={busy||!reason.trim()} onClick={()=>act(`/proposals/${row.id}/decision`,{decision:'refused',reason})}>Recusar esta versão</button></div><p className="help">Decisão fixa apenas esta versão. Não edita a proposta, reconhece rendimento ou valida IVA.</p></form>;
|
||||
}
|
||||
|
||||
export default function CatalogueAccountingWorkspace({context,can,api,perform,busy}) {
|
||||
const [list,setList]=useState(null),[error,setError]=useState(''),[kind,setKind]=useState('plan'),[data,setData]=useState({}),[previous,setPrevious]=useState(null),[replaces,setReplaces]=useState(''),[from,setFrom]=useState(''),[until,setUntil]=useState(''),[reason,setReason]=useState('');
|
||||
const [service,setService]=useState(''),[date,setDate]=useState(''),[resolution,setResolution]=useState(null),[area,setArea]=useState('service_drafts'),[operations,setOperations]=useState([]),[operation,setOperation]=useState(''),[history,setHistory]=useState([]);
|
||||
const prefix='/api/catalogue-accounting';
|
||||
async function load(){setList(await api(prefix));}
|
||||
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
|
||||
async function act(path,body) {await perform(async()=>{await api(prefix+path,'POST',body,context.token,crypto.randomUUID());await load();},'Operação de classificação guardada; sem lançamentos.');}
|
||||
function fresh(type){setKind(type);setData(type==='plan'?{plan:'PGC-NIRF',source_version:list.reference.source_version}:type==='account'?{origin:'enterprise',movable:false}:{});setPrevious(null);setReplaces('');setFrom('');setUntil('');setReason('');}
|
||||
function revise(row){setKind(row.kind);setData({...row.data});setPrevious(row);setReplaces('');setFrom('');setUntil('');setReason('');}
|
||||
if(!can('catalogue_accounting.view'))return <section className="panel"><h1>Acesso não autorizado</h1></section>;
|
||||
if(!list)return <section className="panel"><p>{error||'A carregar classificação…'}</p></section>;
|
||||
const rows=list.proposals, approved=type=>rows.filter(r=>r.kind===type&&r.state==='approved');
|
||||
const defaults=kind==='plan'?{plan:'PGC-NIRF',source_version:list.reference.source_version}:kind==='account'?{plan_id:'',code:'',name:'',origin:'enterprise',movable:false,evidence:''}:kind==='category'?{name:'',account_id:'',conditions:''}:kind==='service_link'?{service_id:'',category_id:''}:{service_id:'',account_id:'',conditions:''};
|
||||
const fields={...defaults,...data};
|
||||
const title=row=>`${kinds[row.kind]} #${row.id} · v${row.version} · ${(row.kind==='account'?row.data.code+' — '+row.data.name:row.data.name)||row.data.code||row.data.plan||`Serviço #${row.data.service_id}`}`;
|
||||
const displayField=(field,value)=>{if(typeof value==='boolean')return value?'Sim':'Não';if(field==='origin')return value==='enterprise'?'Desdobramento empresarial':'Referência oficial agregadora';if(field==='service_id')return list.services.find(r=>r.id===value)?.name||'Serviço #'+value;if(field.endsWith('_id')){const ref=rows.find(r=>r.id===value);return ref?title(ref):'Referência #'+value;}return String(value);};
|
||||
const refOptions=field=>field==='service_id'?list.services.map(s=>({value:s.id,label:`${s.name} · v${s.version}`})):approved({plan_id:'plan',account_id:'account',category_id:'category'}[field]).map(r=>({value:r.id,label:title(r)}));
|
||||
async function consult(){await perform(async()=>setResolution(await api(`${prefix}/services/${service}/resolve${date?`?reference_date=${date}`:''}`)),'');}
|
||||
async function loadOperations(){await perform(async()=>{const result=await api(`/api/a/${area}`);setOperations(result.records);setOperation('');setHistory([]);},'');}
|
||||
async function readHistory(){await perform(async()=>setHistory((await api(`${prefix}/history/${area}/${operation}`)).records),'');}
|
||||
async function captureResolution(){const record=operations.find(r=>String(r.id)===operation);await perform(async()=>{await api(prefix+'/resolutions','POST',{area,record_id:record.id,record_version:record.version,service_id:Number(service),reference_date:date,resolution_revision:resolution.revision},context.token,crypto.randomUUID());await load();setHistory((await api(`${prefix}/history/${area}/${operation}`)).records);},'Resolução consultada preservada; sem lançamentos.');}
|
||||
return <>
|
||||
<section className="panel"><p className="eyebrow">CATÁLOGO DA EMPRESA · PGC-NIRF</p><h1>Classificação contabilística</h1><p>{list.notice}</p><p>Categoria como regra-base; só uma excepção aprovada, vigente e compatível a substitui. Correcções e mudanças de categoria exigem nova versão e validação.</p>{error&&<p role="alert">{error}</p>}<h2>Referências documentais disponíveis</h2><p>{list.reference.source_version}</p><p className="help">{list.reference.status}</p><ul>{list.reference.accounts.map(a=><li key={a.code}>{a.code} — {a.name} · referência agregadora, sem movimento</li>)}</ul><p className="help">Não há conta de movimento empresarial criada por omissão. Quem prepara propõe o desdobramento documentado; quem valida decide.</p></section>
|
||||
{can('catalogue_accounting.propose')&&<section className="panel"><h2>{previous?`Nova versão da proposta #${previous.id}`:'Preparar proposta'}</h2><form onSubmit={async e=>{e.preventDefault();await act('/proposals',{kind,data:Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,k.endsWith('_id')?Number(v):v])),valid_from:from,valid_until:until||null,reason,...(previous?{previous_id:previous.id}:{}),...(replaces?{replaces_id:Number(replaces)}:{})});}}>
|
||||
<label>Tipo de proposta<select disabled={!!previous} value={kind} onChange={e=>fresh(e.target.value)}>{Object.entries(kinds).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label>
|
||||
{Object.entries(fields).map(([field,value])=><label key={field}>{labels[field]}{field==='movable'?<input type="checkbox" checked={!!value} onChange={e=>setData({...fields,[field]:e.target.checked})}/>:field.endsWith('_id')?<select required value={value} onChange={e=>setData({...fields,[field]:e.target.value})}><option value="">Seleccionar referência</option>{refOptions(field).map(o=><option key={o.value} value={o.value}>{o.label}</option>)}</select>:field==='origin'?<select value={value} onChange={e=>setData({...fields,[field]:e.target.value})}><option value="enterprise">Desdobramento empresarial proposto</option><option value="reference">Referência oficial agregadora 7.2</option></select>:['conditions','evidence'].includes(field)?<textarea required maxLength={2000} value={value} onChange={e=>setData({...fields,[field]:e.target.value})}/>:<input required readOnly={kind==='plan'||(!!previous&&field==='code')} maxLength={255} value={value} onChange={e=>setData({...fields,[field]:e.target.value})}/>}</label>)}
|
||||
<div className="form-grid"><label>Vigente desde<input required type="date" value={from} onChange={e=>setFrom(e.target.value)}/></label><label>Vigente até (inclusive; opcional)<input type="date" value={until} onChange={e=>setUntil(e.target.value)}/></label></div><p className="help">Datas de classificação, não de reconhecimento ou exigibilidade de IVA. Nenhuma data é presumida.</p>
|
||||
{previous&&<label>Versão aprovada explicitamente substituída<select value={replaces} onChange={e=>setReplaces(e.target.value)}><option value="">Nenhuma — sem sobreposição</option>{rows.filter(r=>r.kind===previous.kind&&r.entity_key===previous.entity_key&&r.state==='approved').map(r=><option key={r.id} value={r.id}>{title(r)}</option>)}</select></label>}
|
||||
<label>Motivo e fundamento<textarea required maxLength={2000} value={reason} onChange={e=>setReason(e.target.value)}/></label><div className="actions"><button className="primary" disabled={busy}>Guardar proposta em preparação</button>{previous&&<button type="button" onClick={()=>fresh(kind)}>Cancelar correcção</button>}</div><p className="help">Guardar não submete nem aprova. Uma versão guardada é imutável; correcções criam outra versão.</p></form></section>}
|
||||
<section className="panel"><h2>Propostas, submissões e decisões</h2><button disabled={busy} onClick={()=>perform(load,'')}>Actualizar lista</button>{!rows.length&&<p>Nenhuma proposta nesta empresa.</p>}{[...rows].reverse().map(row=><article className="accounting-proposal" key={row.id}><h3>{title(row)}</h3><p><strong>{states[row.state]}</strong> · {row.valid_from} até {row.valid_until||'sem termo definido'} · Autor #{row.actor_id}</p><dl>{Object.entries(row.data).map(([k,v])=><div key={k}><dt>{labels[k]||k}</dt><dd>{displayField(k,v)}</dd></div>)}</dl><p>Fundamento: {row.reason}</p>{row.replaces_id&&<p>Substitui explicitamente a versão aprovada #{row.replaces_id}, a partir do início da nova vigência.</p>}{row.decision&&<p>Decisão por #{row.decision.actor_id}: {row.decision.reason}</p>}
|
||||
{can('catalogue_accounting.propose')&&<div className="actions">{row.state==='draft'&&<button disabled={busy} onClick={()=>act(`/proposals/${row.id}/submit`,{})}>Submeter versão #{row.id}</button>}{!rows.some(other=>other.kind===row.kind&&other.entity_key===row.entity_key&&other.version>row.version)&&<button disabled={busy} onClick={()=>revise(row)}>Preparar nova versão de #{row.id}</button>}</div>}<Decision row={row} can={can} busy={busy} act={act}/></article>)}</section>
|
||||
<section className="panel"><h2>Consultar classificação do serviço</h2><label>Serviço<select value={service} onChange={e=>{setService(e.target.value);setResolution(null);}}><option value="">Seleccionar serviço existente</option>{list.services.map(s=><option value={s.id} key={s.id}>{s.name}</option>)}</select></label><label>Data de referência<input type="date" value={date} onChange={e=>{setDate(e.target.value);setResolution(null);}}/></label><button disabled={busy||!service} onClick={consult}>Consultar classificação</button>{resolution&&<div role="status"><h3>{resolution.state==='resolved_classification_only'?'Classificação resolvida — sem reconhecimento':'Classificação pendente'}</h3>{resolution.origin&&<p>Origem: {resolution.origin==='category_base'?'Regra-base da categoria':'Excepção explícita do serviço'}</p>}{resolution.references&&<ul>{Object.values(resolution.references).map(r=><li key={r.id}>{title(r)} · {r.valid_from} até {r.valid_until||'sem termo declarado'}</li>)}</ul>}<ul>{resolution.pending.map((p,i)=><li key={i}>{p}</li>)}</ul></div>}</section>
|
||||
{context.unit&&<section className="panel"><h2>Classificação preservada por operação autorizada</h2><p>Operações apenas da unidade activa. Não altera a fotografia B1 ou os dados do rascunho.</p><label>Área<select value={area} onChange={e=>{setArea(e.target.value);setOperations([]);setOperation('');setHistory([]);}}><option value="service_drafts">Prestações em rascunho</option><option value="billing_drafts">Rascunhos de facturação</option></select></label><button disabled={busy} onClick={loadOperations}>Carregar operações autorizadas</button><label>Operação<select value={operation} onChange={e=>{setOperation(e.target.value);setHistory([]);}}><option value="">Seleccionar operação</option>{operations.map(r=><option key={r.id} value={r.id}>{r.name} · #{r.id} · v{r.version}</option>)}</select></label><div className="actions"><button disabled={busy||!operation} onClick={readHistory}>Consultar histórico preservado</button>{can('catalogue_accounting.propose')&&<button disabled={busy||!operation||!service||!date||resolution?.state!=='resolved_classification_only'} onClick={captureResolution}>Guardar resolução na operação</button>}</div>{history.map(r=><article key={r.id}><h3>Resolução histórica #{r.id} · operação v{r.record_version}</h3><p>Data de referência {r.reference_date} · autor #{r.actor_id} · serviço {r.snapshot.service.name} v{r.snapshot.service.version}</p><ul>{Object.values(r.snapshot.references).map(ref=><li key={ref.id}>{title(ref)}</li>)}</ul><p>{r.snapshot.notice}</p></article>)}</section>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const names = { issuer: 'Dados do emitente', client: 'Dados dos clientes', payment: 'Instruções de pagamento', service_drafts: 'Preparação de prestações', billing_drafts: 'Preparação de facturação' };
|
||||
const profileFields = { nuit: 'NUIT', address: 'Morada', locality: 'Localidade', country: 'País' };
|
||||
const paymentFields = { label: 'Designação das instruções', bank: 'Banco (se aplicável)', holder: 'Titular (se aplicável)', account_number: 'Identificador de conta (se aplicável)', nib: 'NIB (se aplicável)', iban: 'IBAN (se aplicável)', swift: 'SWIFT (se aplicável)', bank_code: 'Código bancário (se aplicável)', currency: 'Moeda das instruções (opcional)' };
|
||||
const states = { missing: 'Por completar / confirmar aplicabilidade', filled_not_validated: 'Preenchido — não validado', validation_pending: 'Validação pendente', stale: 'Origem alterada — rever' };
|
||||
const isDraft = kind => kind.endsWith('_drafts');
|
||||
|
||||
export default function DocumentPreparationWorkspace({ context, can, api, perform, busy }) {
|
||||
const initial = can('issuer_documents.view') ? 'issuer' : can('client_documents.view') ? 'client' : 'billing_drafts';
|
||||
const [kind, setKind] = useState(initial);
|
||||
return <DocumentPreparationArea key={kind} kind={kind} onSelect={setKind} context={context} can={can} api={api} perform={perform} busy={busy} />;
|
||||
}
|
||||
|
||||
function DocumentPreparationArea({ kind, onSelect, context, can, api, perform, busy }) {
|
||||
const prefix = kind => kind === 'client' ? 'client_documents' : isDraft(kind) ? 'draft_documents' : 'issuer_documents';
|
||||
const permitted = (kind, action) => can(`${prefix(kind)}.${action}`, isDraft(kind) ? context.unit?.id ?? null : null);
|
||||
const options = Object.keys(names).filter(kind => permitted(kind, 'view') && (!isDraft(kind) || context.unit));
|
||||
const [records, setRecords] = useState([]); const [selectedId, setSelectedId] = useState(null);
|
||||
const [record, setRecord] = useState(null); const [form, setForm] = useState({}); const [payments, setPayments] = useState([]); const [error, setError] = useState('');
|
||||
const path = `/api/document-preparation/${kind}`;
|
||||
|
||||
async function load() { const result = await api(path); setRecords(result.records); }
|
||||
async function open(id) {
|
||||
setError('');
|
||||
try {
|
||||
const result = await api(`${path}/${id}`); setRecord(result); setSelectedId(id);
|
||||
setForm({ ...result.data, ...(isDraft(kind) ? { currency: result.currency || '', price_mode: result.data.price_mode || 'undefined', units: result.lines.map((_, i) => result.data.units?.[i] || ''), payment_instruction_ids: result.data.payment_instruction_ids || [] } : {}) });
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
useEffect(() => { setRecord(null); setSelectedId(null); setError(''); load().then(() => { if (kind === 'issuer') open(context.company.id); }).catch(e => setError(e.message)); if (isDraft(kind) && can('issuer_documents.view')) api('/api/document-preparation/payment').then(data => setPayments(data.records)).catch(e => setError(e.message)); }, [kind, context.token]);
|
||||
|
||||
function change(field, value) { setForm(current => ({ ...current, [field]: value })); }
|
||||
async function save(e) {
|
||||
e.preventDefault();
|
||||
const fields = kind === 'payment' ? [...Object.keys(paymentFields), 'instructions'] : isDraft(kind) ? ['service_date','period_start','period_end','payment_terms','proposed_due_date','price_mode','units','payment_instruction_ids','currency'] : Object.keys(profileFields);
|
||||
const data = Object.fromEntries(fields.map(field => [field, form[field] === undefined || form[field] === '' ? null : form[field]]));
|
||||
data.version = record?.version || 0;
|
||||
if (isDraft(kind)) data.draft_version = record.draft_version;
|
||||
await perform(async () => { const result = await api(`${path}/${selectedId ?? 0}`, 'PATCH', data, context.token); await load(); await open(result.id); }, 'Preparação guardada com histórico. Preenchido não significa validado.');
|
||||
}
|
||||
const editable = permitted(kind, 'edit') && !record?.locked;
|
||||
const text = (field, label, type = 'text') => <label key={field}>{label}<input type={type} value={form[field] || ''} readOnly={!editable} maxLength={500} onChange={e => change(field, e.target.value)} /></label>;
|
||||
return <><div className="page-title"><p className="eyebrow">PREPARAÇÃO DOCUMENTAL</p><h1>Dados para facturação</h1><p>Completar e reutilizar informação. Sem emissão fiscal, recebimentos ou lançamentos.</p></div><div className="actions">{options.map(option => <button key={option} className={kind === option ? 'primary' : ''} onClick={() => onSelect(option)}>{names[option]}</button>)}</div>
|
||||
{error && <div className="alert error" role="alert">{error}</div>}
|
||||
{!options.length ? <section className="panel"><p>Sem acesso documental explícito neste contexto.</p></section> : <div className="a-grid"><section className="panel a-list"><h2>{names[kind]}</h2>{kind === 'payment' && editable && <button className="primary" onClick={() => { setSelectedId(0); setRecord({ version: 0, data: {}, review: [] }); setForm({}); }}>Novas instruções</button>}{!records.length && <p>Ainda não existem registos. Clientes e rascunhos são criados nos menus existentes.</p>}{records.map(item => <button key={item.id} onClick={() => open(item.id)}>{item.name || item.data?.label || `#${item.id}`}</button>)}</section>
|
||||
<section className="panel a-detail">{record ? <><h2>{record.name || names[kind]}</h2><p className="help">{record.origin || 'Instruções do emitente'} · Versão {record.version}</p>{record.locked && <p className="alert">Confirmação B1 preservada. Esta preparação está bloqueada para edição.</p>}<form onSubmit={save}>
|
||||
{['issuer','client'].includes(kind) && <><label>Denominação reutilizada do cadastro<input value={record.name || ''} readOnly /></label><p className="help">O nome não é duplicado. Para o alterar, use Empresa ou Clientes, conforme os seus direitos. O cliente pode continuar a ser criado apenas com nome.</p><div className="form-grid">{Object.entries(profileFields).map(([field,label]) => text(field,label))}</div></>}
|
||||
{kind === 'payment' && <><p>Configuração do emitente para facilitar o pagamento. Não é uma pergunta ao cliente sobre como pagará e não cria qualquer recebimento.</p><p className="help">Não introduza palavras-passe, tokens, PIN, códigos de segurança ou dados de cartões. Dados introduzidos pelo utilizador, sem verificação bancária.</p><div className="form-grid">{Object.entries(paymentFields).map(([field,label]) => text(field,label))}</div><label>Texto de instruções<textarea value={form.instructions || ''} readOnly={!editable} maxLength={2000} onChange={e => change('instructions',e.target.value)} /></label></>}
|
||||
{isDraft(kind) && <><p className="help">Versão actual do rascunho: {record.draft_version}. Valores e linhas existentes são reutilizados; este ecrã não recalcula impostos.</p>{text('currency','Moeda do rascunho (opcional)')}<label>Modo de preço<select disabled={!editable} value={form.price_mode} onChange={e => change('price_mode',e.target.value)}><option value="undefined">Por definir</option><option value="included">Imposto incluído</option><option value="added">Imposto acrescido</option></select></label><p className="help">Esta escolha prepara uma intenção; não muda os preços existentes, a base aritmética ou os impostos.</p><div className="form-grid">{text('service_date','Data do serviço (opcional)','date')}{text('period_start','Início do período (opcional)','date')}{text('period_end','Fim do período (opcional)','date')}{text('proposed_due_date','Vencimento proposto (não é emissão)','date')}</div><label>Condições de pagamento<textarea value={form.payment_terms || ''} readOnly={!editable} maxLength={1000} onChange={e => change('payment_terms',e.target.value)} /></label><p className="help">Confira prazo e vencimento. A compatibilidade fica pendente de validação; nenhuma data é corrigida automaticamente.</p>{record.lines.map((line,i) => <label key={i}>Unidade de medida — linha {i + 1}: {line.service_name}<input value={form.units?.[i] || ''} readOnly={!editable} maxLength={80} onChange={e => change('units',form.units.map((unit,index) => index === i ? e.target.value : unit))} /></label>)}<h3>Instruções do emitente aplicáveis a este rascunho</h3><p>Seleccione instruções já configuradas, sem redigitação. A selecção não regista um pagamento.</p>{!payments.length && <p className="help">Não há instruções disponíveis, ou falta acesso explícito ao emitente.</p>}{payments.map(payment => <label className="doc-instruction-option" key={payment.id}><input type="checkbox" disabled={!editable} checked={form.payment_instruction_ids.includes(payment.id)} onChange={e => change('payment_instruction_ids',e.target.checked ? [...form.payment_instruction_ids,payment.id] : form.payment_instruction_ids.filter(id => id !== payment.id))} />{payment.data.label} · versão {payment.version} · não verificado pelo banco</label>)}</>}
|
||||
{editable && <button className="primary" disabled={busy}>Guardar preparação</button>}{!permitted(kind,'edit') && <p>O seu acesso permite apenas consulta.</p>}
|
||||
</form><section className="a-pending"><h3>Completude e validações</h3><p>Sem validação fiscal. Dados preenchidos não significam aptidão para emissão.</p><ul>{record.review.map((check,i) => <li key={i}><strong>{check.label}: {states[check.state]}</strong><p className="help">Origem: {check.origin}. Próxima acção: {check.next}</p></li>)}</ul>{Object.entries(record.related_profiles || {}).map(([profile,value]) => <details key={profile}><summary>{profile === 'issuer' ? 'Emitente' : 'Cliente'}: {value.name} · versão documental {value.version} · reutilizado do cadastro</summary><ul>{value.review.map((check,i) => <li key={i}>{check.label}: {states[check.state]} — {check.next}</li>)}</ul></details>)}</section></> : <p>Seleccione um registo para consultar ou preparar os dados existentes.</p>}</section></div>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const notice = 'RASCUNHO — SEM VALIDADE FISCAL';
|
||||
const missing = historical => historical ? 'Não capturado nesta confirmação' : 'Por completar';
|
||||
const present = (value, historical) => value === null || value === undefined || value === '' ? missing(historical) : value;
|
||||
const modes = { undefined: 'Por definir', included: 'Imposto incluído — indicação proposta', added: 'Imposto acrescido — indicação proposta' };
|
||||
|
||||
export default function DocumentPreview({ id, historical, context, api, onClose }) {
|
||||
const [view, setView] = useState(null); const [error, setError] = useState(''); const [stale, setStale] = useState(false); const [loading, setLoading] = useState(true);
|
||||
const path = `/api/document-preview/${historical ? 'history' : 'draft'}/${id}`;
|
||||
async function update() {
|
||||
setLoading(true); setError('');
|
||||
try { const result = await api(path); setView(result); setStale(false); }
|
||||
catch (e) { setView(null); setError(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
setView(null); setLoading(true);
|
||||
api(path).then(result => { if (!disposed) { setView(result); setLoading(false); } }).catch(e => { if (!disposed) { setError(e.message); setLoading(false); } });
|
||||
return () => { disposed = true; };
|
||||
}, [id, historical, context.token]);
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
let disposed = false; let checking = false;
|
||||
async function check() {
|
||||
if (document.hidden || checking) return; checking = true;
|
||||
try { const result = await api(`${path}/revision`); if (!disposed && result.revision !== view.revision) setStale(true); }
|
||||
catch (e) { if (!disposed) { setView(null); setError(e.message); } }
|
||||
finally { checking = false; }
|
||||
}
|
||||
const timer = setInterval(check, 10000); window.addEventListener('focus',check); document.addEventListener('visibilitychange',check);
|
||||
return () => { disposed = true; clearInterval(timer); window.removeEventListener('focus',check); document.removeEventListener('visibilitychange',check); };
|
||||
}, [view?.revision, context.token]);
|
||||
|
||||
return <div className="preview-shell"><div className="preview-mandatory" role="note">{notice}</div><div className="preview-controls"><button onClick={onClose}>Voltar ao registo</button><button disabled={loading} onClick={update}>Actualizar pré-visualização</button><p className="help">Consulta sem emissão ou alteração de dados. Verificação de actualidade a cada 10 segundos e ao regressar à janela.</p></div>
|
||||
{error && <div className="alert error" role="alert">{error}</div>}{loading && <p role="status">A preparar a consulta…</p>}
|
||||
{stale && <div className="preview-stale" role="alert"><strong>Pré-visualização desactualizada.</strong> Os dados abaixo mantêm a versão consultada. Use Actualizar pré-visualização para carregar explicitamente a nova versão.</div>}
|
||||
{view && <article className="preview-sheet" aria-label={historical ? 'Confirmação interna histórica' : 'Rascunho de factura de serviço'}><div className="preview-document-heading"><div>{view.logo && <img src={view.logo} alt="Logótipo local do emitente" className="preview-logo" />}<p className="eyebrow">{historical ? 'CONSULTA DA FOTOGRAFIA PRESERVADA' : 'PRÉ-VISUALIZAÇÃO DOCUMENTAL'}</p><h1>{view.title}</h1></div><p>{notice}</p></div>
|
||||
<p><strong>{view.reference}</strong> · Versão do rascunho {view.draft_version}{historical ? ` · Fotografia esquema ${view.snapshot_schema}` : ` · Preparação ${view.preparation_version} · Apresentação ${view.model_version}`}</p>
|
||||
<p className="help">Instante desta consulta: {view.consulted_at}. Não é data de emissão.</p>
|
||||
{historical && <p className="preview-historical">Confirmação interna histórica de {view.confirmed_at}, autor #{view.actor_id}. Conteúdo exclusivamente da fotografia; campos ausentes não foram reconstruídos com dados actuais.</p>}
|
||||
<div className="preview-parties">{[['Emitente',view.issuer],['Cliente',view.client]].map(([label,party]) => <section key={label}><h2>{label}</h2><strong>{present(party.name,historical)}</strong><p>NUIT: {present(party.data.nuit,historical)}</p><p>{present(party.data.address,historical)}<br />{present(party.data.locality,historical)} · {present(party.data.country,historical)}</p></section>)}</div>
|
||||
{view.contacts && <section><h2>Contactos do emitente</h2><p className="preview-text">{view.contacts}</p></section>}
|
||||
<section><h2>Serviço e condições propostas</h2><div className="preview-dates"><p>Data do serviço: <strong>{present(view.prepared.service_date,historical)}</strong></p><p>Período: <strong>{present(view.prepared.period_start,historical)} — {present(view.prepared.period_end,historical)}</strong></p><p>Vencimento proposto: <strong>{present(view.prepared.proposed_due_date,historical)}</strong></p></div><p className="preview-text">Condições: {present(view.prepared.payment_terms,historical)}</p><p>Moeda: <strong>{view.data.currency || 'Moeda por definir'}</strong> · {modes[view.prepared.price_mode || 'undefined']}</p><p className="help">Origem: prestação #{view.source_id}, versão {view.data.source_version ?? 'não capturada'}. Os dados não confirmam execução do serviço.</p></section>
|
||||
<section><h2>Linhas do rascunho</h2><div className="preview-lines">{view.data.lines.map((line,i) => <div className="preview-line" key={i}><div><strong>{i + 1}. {line.service_name}</strong>{line.description && <p className="preview-text">{line.description}</p>}</div><p>Quantidade<br /><strong>{present(line.quantity,historical)}</strong></p><p>Unidade<br /><strong>{present(view.prepared.units?.[i],historical)}</strong></p><p>Preço proposto<br /><strong>{present(line.unit_price,historical)}</strong></p></div>)}</div></section>
|
||||
<section className="preview-values"><p>Valor aritmético provisório <strong>{view.base_amount ?? 'Por completar'} {view.data.currency || '(moeda por definir)'}</strong></p><p>Imposto por apurar <strong>Não apurado</strong></p><p>Total definitivo por apurar <strong>Não apurado</strong></p><p className="help">Valores exactos propostos, sem cálculo fiscal, novas taxas ou arredondamentos. Não representam base tributável ou montante monetário definitivo. Um preço com imposto incluído desconhecido não foi decomposto.</p></section>
|
||||
{!historical && view.calculation && <section aria-label="Cálculo de serviço"><h2>Cálculo de serviço</h2>{view.calculation.pending.map((text,i)=><p key={i}>{text}</p>)}
|
||||
{view.calculation.environment === 'synthetic_test_only' && <><strong>ENSAIO SINTÉTICO — APENAS TESTES</strong><p>{view.calculation.policy} · Truncagem operacional proposta; não é obrigação legal comprovada.</p>
|
||||
{view.calculation.can_calculate && view.calculation.profiles.map(profile=><button key={profile.id} disabled={loading || stale} onClick={async()=>{setLoading(true);try { await api(`/api/service-calculations/${id}`,'POST',{draft_version:view.calculation.draft_version,preparation_version:view.calculation.preparation_version,profile_id:profile.id,profile_version:profile.version}); await update(); } catch(e) {setError(e.message);setLoading(false);} }}>Calcular ensaio · {profile.identifier} v{profile.version}</button>)}
|
||||
{view.calculation.history.map(entry=><div key={entry.id} className="preview-values"><h3>Resultado de ensaio #{entry.id} — {entry.current ? 'Actual para estas versões' : 'Desactualizado — apenas histórico'}</h3><p>Rascunho v{entry.snapshot.input.draft_version} · Preparação v{entry.snapshot.input.preparation_version} · Perfil {entry.snapshot.input.profile_identifier} v{entry.snapshot.input.profile_version}</p>
|
||||
<p>Base aritmética exacta: <strong>{entry.snapshot.result.base_exact}</strong></p><p>IVA exacto: <strong>{entry.snapshot.result.vat_exact}</strong></p><p>IVA truncado proposto: <strong>{entry.snapshot.result.vat_document_proposed}</strong></p><p>Diferença descartada: <strong>{entry.snapshot.result.discarded_difference}</strong></p><p>Total documental proposto: <strong>{entry.snapshot.result.total_document_proposed ?? 'Pendente: representação da base/total'}</strong> {entry.snapshot.result.currency}</p>{entry.snapshot.result.pending.map((text,i)=><p key={i}>{text}</p>)}<p>Sem validação fiscal. {notice}</p></div>)}
|
||||
</>}
|
||||
</section>}
|
||||
{!!view.payments.length && <section><h2>Instruções do emitente para facilitar o pagamento</h2><p className="help">Informação introduzida pelo utilizador, sem verificação bancária. Não regista recebimento nem inicia operação bancária.</p>{view.payments.map(payment => <div key={payment.id} className="preview-payment"><h3>{payment.data.label} · versão {payment.version}</h3>{Object.entries({ bank:'Banco', holder:'Titular', account_number:'Conta', nib:'NIB', iban:'IBAN', swift:'SWIFT', bank_code:'Código bancário', currency:'Moeda' }).filter(([key]) => payment.data[key]).map(([key,label]) => <p key={key}>{label}: <strong>{payment.data[key]}</strong></p>)}{payment.data.instructions && <p className="preview-text">{payment.data.instructions}</p>}</div>)}</section>}
|
||||
{view.observations && <section><h2>Observações</h2><p className="preview-text">{view.observations}</p></section>}
|
||||
<section className="preview-pending" aria-label="Pendências obrigatórias"><h2>Dados em falta e validações pendentes</h2><p>Preenchido não significa validado. Imposto e total definitivo continuam por apurar.</p><ul>{Object.entries(view.pending).map(([code,text]) => <li key={code}>{text}</li>)}</ul></section><p className="preview-end-notice">{notice}</p>
|
||||
</article>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function DocumentPreviewConfiguration({ api, perform, can, busy, context }) {
|
||||
const [model,setModel] = useState(null); const [form,setForm] = useState(null); const [error,setError] = useState('');
|
||||
async function load() { const result = await api('/api/document-preview/model'); setModel(result); setForm(result.data); }
|
||||
useEffect(() => { load().catch(e => setError(e.message)); }, [context.token]);
|
||||
const editable = can('document_preview.configure');
|
||||
return <section className="panel"><p className="eyebrow">APRESENTAÇÃO POR EMPRESA</p><h1>Apresentação do rascunho</h1><p>Configuração delimitada; o aviso de rascunho e todas as pendências são obrigatórios.</p>{error && <p role="alert">{error}</p>}{model && <form onSubmit={e => { e.preventDefault(); perform(async () => { await api('/api/document-preview/model','PATCH',{ version:model.version,...form }); await load(); }, 'Apresentação guardada; não altera documentos ou fotografias B1.'); }}><p>Versão {model.version} · {model.local_logo_available ? 'Logótipo local disponível' : 'Logótipo não disponível: falta recurso local autorizado'}</p>{Object.entries({show_logo:'Apresentar logótipo local da empresa',show_contacts:'Apresentar contactos',show_observations:'Apresentar observações',show_payment_instructions:'Apresentar instruções de pagamento seleccionadas no rascunho'}).map(([field,label]) => <label className="doc-instruction-option" key={field}><input type="checkbox" disabled={!editable} checked={form[field]} onChange={e => setForm({...form,[field]:e.target.checked})} />{label}</label>)}<label>Contactos de apresentação<textarea readOnly={!editable} value={form.contact_text} maxLength={2000} onChange={e => setForm({...form,contact_text:e.target.value})} /></label><label>Observações de apresentação<textarea readOnly={!editable} value={form.observations} maxLength={2000} onChange={e => setForm({...form,observations:e.target.value})} /></label><p className="help">Apenas texto, sem HTML, recursos externos ou segredos. A escolha de instruções é reutilizada da preparação documental; não é uma pergunta sobre como o cliente pagará.</p>{editable ? <button className="primary" disabled={busy}>Guardar apresentação</button> : <p>Apenas consulta; não tem configuração concedida.</p>}</form>}</section>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DocumentPreview from './DocumentPreview.jsx';
|
||||
|
||||
export const serviceAreas = { clients: 'Clientes', services: 'Serviços', service_drafts: 'Prestações em rascunho', billing_drafts: 'Rascunhos de facturação' };
|
||||
const actions = { view: 'Consultar', create: 'Criar', edit: 'Editar', manage: 'Gerir concessões' };
|
||||
@@ -16,6 +17,7 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
|
||||
const [key, setKey] = useState(() => crypto.randomUUID());
|
||||
const [access, setAccess] = useState(false);
|
||||
const [reviewConfirmation, setReviewConfirmation] = useState(false);
|
||||
const [previewMode, setPreviewMode] = useState(null);
|
||||
const path = `/api/a/${area}`;
|
||||
|
||||
async function load() { const result = await api(path); setRecords(result.records); }
|
||||
@@ -67,6 +69,7 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
|
||||
function changeLine(index, field, value) { setForm({ ...form, lines: form.lines.map((line, n) => n === index ? { ...line, [field]: value } : line) }); }
|
||||
|
||||
if (!allowed('view')) return <section className="panel"><h1>{serviceAreas[area]}</h1><p>Sem consulta autorizada neste contexto.</p></section>;
|
||||
if (previewMode && selected) return <DocumentPreview id={selected.id} historical={previewMode === 'history'} context={context} api={api} onClose={() => setPreviewMode(null)} />;
|
||||
return <>
|
||||
<div className="a-heading"><div><p className="eyebrow">SERVIÇOS · PREPARAÇÃO E CONFIRMAÇÃO INTERNA</p><h1>{serviceAreas[area]}</h1><p className="muted">{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}</p></div><div className="actions">{allowed('create') && <button className="primary" disabled={busy} onClick={() => start()}>Novo registo</button>}{allowed('manage') && <button onClick={() => { setAccess(!access); setForm(null); }}>Gerir acessos desta área</button>}</div></div>
|
||||
{error && <div role="alert" className="alert error">{error}</div>}
|
||||
@@ -91,6 +94,7 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
|
||||
{area === 'billing_drafts' && allowed('edit') && !selected.confirmation && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
|
||||
{area === 'billing_drafts' && !selected.confirmation && can('billing_drafts.confirm_internal', context.unit?.id) && <div className="a-pending"><h3>Confirmação interna</h3><p>Fixa esta versão e os dados que a sustentam. Bloqueia a edição deste rascunho. Não confirma a execução do serviço, não emite factura nem reconhece receita.</p>{reviewConfirmation ? <><p>Confirmar a versão {selected.version} neste contexto: {context.company.name} / {context.unit?.name}? Quantidades e preços devem estar completos; moeda e regras desconhecidas ficam pendentes na fotografia.</p><div className="actions"><button className="primary" disabled={busy} onClick={() => perform(async () => { await api(`/api/b1/billing-drafts/${selected.id}/confirm-internal`, 'POST', { version: selected.version }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Confirmado internamente — sem validade fiscal')}>Confirmar internamente esta versão</button><button onClick={() => setReviewConfirmation(false)}>Voltar ao rascunho</button></div></> : <button disabled={busy} onClick={() => { setKey(crypto.randomUUID()); setReviewConfirmation(true); }}>Rever confirmação interna</button>}</div>}
|
||||
{selected.confirmation && <InternalDocument confirmation={selected.confirmation} />}
|
||||
{area === 'billing_drafts' && (selected.confirmation ? <button onClick={() => setPreviewMode('history')}>Abrir confirmação interna histórica</button> : can('issuer_documents.view') && can('client_documents.view') && can('draft_documents.view',context.unit?.id) && <button className="primary" onClick={() => setPreviewMode('draft')}>Pré-visualizar rascunho</button>)}
|
||||
</>}
|
||||
<details className="a-history"><summary>Histórico e captura durável ({selected.history?.length || 0} versões)</summary>{selected.history?.map(entry => <div key={entry.id}><strong>Versão {entry.version}</strong><p className="help">{entry.created_at} · Autor #{entry.actor_id} · Evento #{entry.event?.id} · Capturado, sem processamento contabilístico</p><details><summary>Dados preservados nesta versão</summary><pre>{JSON.stringify(entry.snapshot, null, 2)}</pre></details></div>)}</details>
|
||||
</> : <><h2>Seleccione um registo</h2><p>Os dados e o histórico aparecem aqui. As operações disponíveis dependem das suas concessões explícitas.</p></>}
|
||||
|
||||
@@ -2,8 +2,11 @@ import React, { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '../css/app.css';
|
||||
import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
|
||||
import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
|
||||
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
|
||||
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
|
||||
|
||||
const labels = { 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
|
||||
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState(null);
|
||||
@@ -46,7 +49,7 @@ function App() {
|
||||
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
|
||||
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
|
||||
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
|
||||
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
|
||||
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
|
||||
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
|
||||
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
|
||||
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
|
||||
@@ -54,6 +57,9 @@ function App() {
|
||||
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
|
||||
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
|
||||
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
|
||||
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
|
||||
</div>}
|
||||
</main><footer>SGF TEMPO · Rascunhos e confirmação interna · Sem emissão fiscal ou lançamentos</footer></div></div>;
|
||||
|
||||
@@ -15,6 +15,22 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
|
||||
Route::post('/api/logout', [IdentityController::class, 'logout']);
|
||||
Route::post('/api/context', [IdentityController::class, 'select']);
|
||||
Route::middleware(RequireCompanyContext::class)->group(function (): void {
|
||||
Route::get('/api/catalogue-accounting', [\App\Modules\Services\Http\CatalogueAccountingController::class,'index']);
|
||||
Route::post('/api/catalogue-accounting/proposals', [\App\Modules\Services\Http\CatalogueAccountingController::class,'propose']);
|
||||
Route::post('/api/catalogue-accounting/proposals/{id}/submit', [\App\Modules\Services\Http\CatalogueAccountingController::class,'submit'])->whereNumber('id');
|
||||
Route::post('/api/catalogue-accounting/proposals/{id}/decision', [\App\Modules\Services\Http\CatalogueAccountingController::class,'decide'])->whereNumber('id');
|
||||
Route::get('/api/catalogue-accounting/services/{service}/resolve', [\App\Modules\Services\Http\CatalogueAccountingController::class,'resolve'])->whereNumber('service');
|
||||
Route::post('/api/catalogue-accounting/resolutions', [\App\Modules\Services\Http\CatalogueAccountingController::class,'capture']);
|
||||
Route::get('/api/catalogue-accounting/history/{area}/{record}', [\App\Modules\Services\Http\CatalogueAccountingController::class,'history'])->whereNumber('record');
|
||||
Route::post('/api/service-calculations/{id}', [\App\Modules\Services\Http\DocumentPreviewController::class, 'calculate'])->whereNumber('id');
|
||||
Route::get('/api/document-preview/model', [\App\Modules\Services\Http\DocumentPreviewController::class, 'model']);
|
||||
Route::patch('/api/document-preview/model', [\App\Modules\Services\Http\DocumentPreviewController::class, 'configure']);
|
||||
Route::get('/api/document-preview/draft/{id}', [\App\Modules\Services\Http\DocumentPreviewController::class, 'draft'])->whereNumber('id');
|
||||
Route::get('/api/document-preview/history/{id}', [\App\Modules\Services\Http\DocumentPreviewController::class, 'historical'])->whereNumber('id');
|
||||
Route::get('/api/document-preview/{mode}/{id}/revision', [\App\Modules\Services\Http\DocumentPreviewController::class, 'revision'])->whereNumber('id');
|
||||
Route::get('/api/document-preparation/{kind}', [\App\Modules\Services\Http\DocumentPreparationController::class, 'index']);
|
||||
Route::get('/api/document-preparation/{kind}/{id}', [\App\Modules\Services\Http\DocumentPreparationController::class, 'show'])->whereNumber('id');
|
||||
Route::patch('/api/document-preparation/{kind}/{id}', [\App\Modules\Services\Http\DocumentPreparationController::class, 'save'])->whereNumber('id');
|
||||
Route::post('/api/b1/billing-drafts/{id}/confirm-internal', [\App\Modules\Services\Http\ServiceController::class, 'confirmInternal'])->whereNumber('id');
|
||||
Route::prefix('api/a/{area}')->group(function (): void {
|
||||
$controller = \App\Modules\Services\Http\ServiceController::class;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$input=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($input['actor'],$input['tenant'],$input['company'],$input['unit']);
|
||||
if($input['pause']??false){$paused=false;\Illuminate\Support\Facades\DB::listen(function($q)use(&$paused){if(!$paused&&str_contains($q->sql,'"companies"')&&str_contains($q->sql,'for update')){$paused=true;echo "LOCK_HELD\n";flush();usleep(1500000);}});}
|
||||
try{$result=app(\App\Modules\Services\CatalogueAccounting::class)->decide($input['proposal'],$input['actor'],['decision'=>$input['decision'],'reason'=>'Decisão sintética concorrente'],$input['key']);echo json_encode(['status'=>200,'result'=>$result],JSON_THROW_ON_ERROR).PHP_EOL;}
|
||||
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){echo json_encode(['status'=>$e->getStatusCode()],JSON_THROW_ON_ERROR).PHP_EOL;}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
|
||||
$c=app(\App\Modules\Services\CatalogueAccounting::class);
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($f['manager_id'],$f['tenant'],$f['company'],$f['unit']);
|
||||
$proposal=$c->propose($f['manager_id'],['kind'=>'category','data'=>['name'=>'Categoria de concorrência sintética','account_id'=>$f['accounting_account'],'conditions'=>'Somente ensaio de decisões concorrentes.'],'valid_from'=>'2026-01-01','reason'=>'Ensaiar concorrência sem operações reais.'],(string)Str::uuid())['id'];$c->submit($proposal,$f['manager_id'],(string)Str::uuid());
|
||||
$base=['actor'=>$f['reader_id'],'tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'proposal'=>$proposal];
|
||||
$a=new Process([PHP_BINARY,'scripts/accounting-concurrency-worker.php','--env=testing'],base_path(),null,json_encode($base+['pause'=>true,'decision'=>'approved','key'=>(string)Str::uuid()]));
|
||||
$b=new Process([PHP_BINARY,'scripts/accounting-concurrency-worker.php','--env=testing'],base_path(),null,json_encode($base+['decision'=>'refused','key'=>(string)Str::uuid()]));
|
||||
$a->start();if(!$a->waitUntil(fn($type,$buffer)=>str_contains($buffer,'LOCK_HELD')))throw new RuntimeException('Concurrent lock not observed.');$b->start();$a->wait();$b->wait();
|
||||
$statuses=[];foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Decision worker failed.');$lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($s)=>str_starts_with($s,'{')));$statuses[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR)['status'];}
|
||||
if($statuses!==[200,409]||DB::table('ca_decisions')->where('proposal_id',$proposal)->count()!==1)throw new RuntimeException('Contradictory decision accepted.');
|
||||
file_put_contents(base_path('docs/proofs/accounting-concurrency.json'),json_encode(['database'=>'sgf_tempo_test','parallel_processes'=>true,'lock_overlap_observed'=>true,'statuses'=>$statuses,'decisions'=>1],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Concurrent approval/refusal verified: one decision accepted, conflicting decision refused (409).\n";
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
use App\Modules\Services\CatalogueAccounting;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||
if(DB::table('ca_proposals')->exists())throw new RuntimeException('Accounting fixture already present; no data deleted.');
|
||||
foreach(['manager_id','reader_id'] as $field)if(!str_ends_with(DB::table('users')->where('id',$f[$field])->value('email'),'@example.test'))throw new RuntimeException('Synthetic identities required.');
|
||||
$result=DB::transaction(function()use($f){
|
||||
foreach(['manager_id'=>'propose','reader_id'=>'approve'] as $field=>$action)foreach(['view',$action] as $right)\Tests\Support\IdentityFixture::grant($f[$field],$f['tenant'],$f['company'],"catalogue_accounting.$right");
|
||||
$select=function(int $actor)use($f){app(\App\Shared\Context\CompanyContext::class)->selectFor($actor,$f['tenant'],$f['company'],$f['unit']);};
|
||||
$select($f['manager_id']);$workflow=app(\App\Modules\Services\ServiceWorkflow::class);
|
||||
$client=$workflow->save('clients',null,$f['manager_id'],['name'=>'Cliente de classificação sintética'],(string)Str::uuid())['id'];
|
||||
$service=$workflow->save('services',null,$f['manager_id'],['name'=>'Apoio administrativo pontual — Ensaio'],(string)Str::uuid())['id'];
|
||||
$draft=$workflow->save('service_drafts',null,$f['manager_id'],['client_id'=>$client,'lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'100']]],(string)Str::uuid())['id'];
|
||||
$create=function(string $kind,array $data,bool $approve=true)use($f,$select){
|
||||
$select($f['manager_id']);$c=app(CatalogueAccounting::class);$id=$c->propose($f['manager_id'],['kind'=>$kind,'data'=>$data,'valid_from'=>'2026-01-01','reason'=>'Proposta exclusivamente sintética para demonstração.'],(string)Str::uuid())['id'];
|
||||
if($approve){$c->submit($id,$f['manager_id'],(string)Str::uuid());$select($f['reader_id']);$c->decide($id,$f['reader_id'],['decision'=>'approved','reason'=>'Validação sintética; não é enquadramento da Tempo Lda.'],(string)Str::uuid());}
|
||||
return $id;
|
||||
};
|
||||
$plan=$create('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION]);
|
||||
$account=$create('account',['plan_id'=>$plan,'code'=>'7.2.900','name'=>'Serviços normais — conta empresarial sintética','origin'=>'enterprise','movable'=>true,'evidence'=>'Desdobramento empresarial de teste, não código oficial proposto à Tempo Lda.']);
|
||||
$category=$create('category',['name'=>'Serviços normais — Categoria sintética','account_id'=>$account,'conditions'=>'Apenas apoio administrativo da actividade normal sintética.']);
|
||||
$link=$create('service_link',['service_id'=>$service,'category_id'=>$category]);
|
||||
$select($f['manager_id']);$c=app(CatalogueAccounting::class);$consulted=$c->resolve($service,'2026-10-26',$f['manager_id']);$resolution=$c->capture($f['manager_id'],['area'=>'service_drafts','record_id'=>$draft,'record_version'=>1,'service_id'=>$service,'reference_date'=>'2026-10-26','resolution_revision'=>$consulted['revision']],(string)Str::uuid())['id'];
|
||||
$refuse=$create('category',['name'=>'Proposta sintética a recusar','account_id'=>$account,'conditions'=>'Evidência incompleta; não activar.'],false);$select($f['manager_id']);$c->submit($refuse,$f['manager_id'],(string)Str::uuid());
|
||||
return ['accounting_service'=>$service,'accounting_draft'=>$draft,'accounting_plan'=>$plan,'accounting_account'=>$account,'accounting_category'=>$category,'accounting_link'=>$link,'accounting_resolution'=>$resolution,'accounting_refuse'=>$refuse];
|
||||
});
|
||||
file_put_contents($file,json_encode($f+$result,JSON_THROW_ON_ERROR));
|
||||
echo "Synthetic classification graph, captured baseline and pending refusal prepared only in sgf_tempo_test. Credentials omitted.\n";
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows())throw new RuntimeException('Own local development database required.');
|
||||
$db=\Illuminate\Support\Facades\DB::class;$summary=[];
|
||||
$afterGrants=in_array('--after-grants',$argv,true);
|
||||
foreach(['a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_pending','a_outbox','a_commands','b_internal_confirmations','b_internal_confirmation_commands','doc_records','doc_history','document_preview_models','document_preview_model_history','service_calculation_profiles','service_calculation_results','permission_grants'] as $table){
|
||||
if(!\Illuminate\Support\Facades\Schema::hasTable($table))continue;
|
||||
$query=$db::table($table);
|
||||
if($table==='permission_grants'&&$afterGrants)$query->whereNot(function($q){$q->where('tenant_id',1)->where('company_id',1)->whereNull('unit_id')->where(function($q){$q->where(fn($q)=>$q->where('user_id',1)->whereIn('permission',['catalogue_accounting.view','catalogue_accounting.propose']))->orWhere(fn($q)=>$q->where('user_id',2)->whereIn('permission',['catalogue_accounting.view','catalogue_accounting.approve']));});});
|
||||
$rows=$query->orderBy($table==='a_outbox'?'event_id':'id')->get();$summary[$table]=['count'=>$rows->count(),'hash'=>hash('sha256',$rows->toJson())];
|
||||
}
|
||||
foreach(['app/Modules/Services/ServiceCalculation.php','database/migrations/2026_10_26_030000_create_service_calculations.php','resources/js/DocumentPreview.jsx'] as $path)$summary['file:'.$path]=hash_file('sha256',base_path($path));
|
||||
$file=storage_path('app/accounting-preservation-before.json');
|
||||
if(in_array('--before',$argv,true)){file_put_contents($file,json_encode($summary,JSON_THROW_ON_ERROR));echo "Existing development records and calculation files fingerprinted; no contents disclosed.\n";exit;}
|
||||
if($summary!==json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR))throw new RuntimeException('Existing data, prior grants or calculation files changed.');
|
||||
$counts=[];foreach(['ca_proposals','ca_submissions','ca_decisions','ca_resolutions','ca_commands'] as $table){$counts[$table]=$db::table($table)->count();if($counts[$table]!==0)throw new RuntimeException('Unexpected real classification rows.');}
|
||||
if($afterGrants){
|
||||
$expected=[1=>['catalogue_accounting.view','catalogue_accounting.propose'],2=>['catalogue_accounting.view','catalogue_accounting.approve']];
|
||||
foreach($expected as $user=>$rights){$actual=$db::table('permission_grants')->where('user_id',$user)->where('permission','like','catalogue_accounting.%')->get(['tenant_id','company_id','unit_id','permission']);if($actual->count()!==2)throw new RuntimeException('Unexpected classification rights.');foreach($actual as $g)if($g->tenant_id!==1||$g->company_id!==1||$g->unit_id!==null||!in_array($g->permission,$rights,true))throw new RuntimeException('Unexpected classification scope.');}
|
||||
file_put_contents(base_path('docs/proofs/accounting-access-results.json'),json_encode(['database'=>'sgf_tempo_dev','confirmed_grants_applied'=>4,'company_id'=>1,'proposer_id'=>1,'validator_id'=>2,'no_unit_grants'=>true,'prior_grants_unchanged'=>true,'business_records_and_calculation_files_unchanged'=>true,'real_classification_rows'=>0],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Exactly four confirmed company grants verified; prior grants, data and calculation unchanged; no unit grants or real classification rows.\n";
|
||||
}else{
|
||||
file_put_contents(base_path('docs/proofs/accounting-dev-preservation.json'),json_encode(['database'=>'sgf_tempo_dev','existing_data_hashes_unchanged'=>true,'existing_grants_unchanged'=>true,'calculation_files_unchanged'=>true,'new_table_counts'=>$counts],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Development migration preserved existing records, grants and calculation files; all five new tables empty.\n";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app=require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
|
||||
$input=['actor'=>$f['manager_id'],'tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'draft'=>$f['calculation_drafts'][1],
|
||||
'command'=>['draft_version'=>1,'preparation_version'=>1,'profile_id'=>$f['calculation_profile'],'profile_version'=>1]];
|
||||
$a=new Process([PHP_BINARY,'scripts/calculation-worker.php','--env=testing'],base_path(),null,json_encode($input+['pause'=>true]));
|
||||
$b=new Process([PHP_BINARY,'scripts/calculation-worker.php','--env=testing'],base_path(),null,json_encode($input));
|
||||
$a->start();
|
||||
if (!$a->waitUntil(fn ($type,$buffer)=>str_contains($buffer,'LOCK_HELD'))) { throw new RuntimeException('No concurrent lock established.'); }
|
||||
$b->start(); $a->wait(); $b->wait();
|
||||
$results=[];
|
||||
foreach ([$a,$b] as $worker) {
|
||||
if (!$worker->isSuccessful()) { throw new RuntimeException('Calculation worker failed.'); }
|
||||
$lines=array_values(array_filter(explode("\n",trim($worker->getOutput())),fn($s)=>str_starts_with($s,'{')));
|
||||
$results[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);
|
||||
}
|
||||
if ($results[0]['id']!==$results[1]['id'] || $results[0]['replayed'] || !$results[1]['replayed'] || DB::table('service_calculation_results')->where('billing_draft_id',$input['draft'])->count()!==1) { throw new RuntimeException('Concurrent duplicate or unexpected replay.'); }
|
||||
file_put_contents(base_path('docs/proofs/calculation-concurrency.json'),json_encode(['database'=>'sgf_tempo_test','two_php_processes'=>true,'lock_overlap_observed'=>true,'same_result_id'=>true,'one_insert_one_replay'=>true,'result_count'=>1],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
echo "Concurrent calculation: one immutable result, one replay, two overlapping PHP processes.\n";
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Modules\Services\ServiceCalculation;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
|
||||
$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($f['manager_id'],$f['tenant'],$f['company'],$f['unit']);
|
||||
$ids=DB::transaction(function () use ($f) {
|
||||
$workflow=app(\App\Modules\Services\ServiceWorkflow::class); $actor=$f['manager_id'];
|
||||
$client=$workflow->save('clients',null,$actor,['name'=>'Cliente sintético de cálculo'],(string)Str::uuid())['id'];
|
||||
$service=$workflow->save('services',null,$actor,['name'=>'Serviço sintético de cálculo'],(string)Str::uuid())['id'];
|
||||
$profile=DB::table('service_calculation_profiles')->insertGetId(['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$f['unit'],'identifier'=>'ENSAIO-MZN-16','version'=>1,'definition'=>json_encode(['kind'=>'synthetic','currency'=>'MZN','rate_percent'=>'16','policy'=>ServiceCalculation::POLICY,'sources'=>ServiceCalculation::SOURCES]),'created_at'=>now()]);
|
||||
$drafts=[];
|
||||
foreach (['100','1000.03','0.03','0.0001'] as $price) {
|
||||
$source=$workflow->save('service_drafts',null,$actor,['name'=>'Ensaio base '.$price,'client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>$price]]],(string)Str::uuid())['id'];
|
||||
$draft=$workflow->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Ensaio base '.$price],(string)Str::uuid())['id'];
|
||||
app(\App\Modules\Services\DocumentPreparation::class)->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','units'=>['UN'],'payment_instruction_ids'=>[]]);
|
||||
$drafts[]=$draft;
|
||||
}
|
||||
return ['calculation_profile'=>$profile,'calculation_drafts'=>$drafts];
|
||||
});
|
||||
file_put_contents($file,json_encode($f+$ids,JSON_THROW_ON_ERROR));
|
||||
echo "Four synthetic calculation drafts and profile created only in sgf_tempo_test. No results precomputed.\n";
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app=require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db=\Illuminate\Support\Facades\DB::class;
|
||||
if (app()->environment()!=='local' || $db::selectOne('select current_database() as db')->db!=='sgf_tempo_dev') { throw new RuntimeException('Development preservation destination refused.'); }
|
||||
$summary=[];
|
||||
foreach (['a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_outbox','a_pending','b_internal_confirmations','doc_records','doc_history','document_preview_models','document_preview_model_history','permission_grants'] as $table) {
|
||||
$rows=$db::table($table)->orderBy($table==='a_outbox' ? 'event_id' : 'id')->get(); $summary[$table]=['count'=>$rows->count(),'sha256'=>hash('sha256',$rows->toJson())];
|
||||
}
|
||||
$file=storage_path('app/calculation-preservation-before.json');
|
||||
if (in_array('--before',$argv,true)) { file_put_contents($file,json_encode($summary,JSON_THROW_ON_ERROR)); echo "Development preservation fingerprints captured; no contents displayed.\n"; }
|
||||
else {
|
||||
if ($summary!==json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR)) { throw new RuntimeException('Existing records changed during migration.'); }
|
||||
foreach (['service_calculation_profiles','service_calculation_results'] as $table) { if ($db::table($table)->count()!==0) { throw new RuntimeException('Unexpected development calculation records.'); } }
|
||||
file_put_contents(base_path('docs/proofs/calculation-dev-preservation.json'),json_encode(['database'=>'sgf_tempo_dev','existing_tables_compared'=>array_keys($summary),'identical_counts_and_content_hashes'=>true,'new_profiles'=>0,'new_results'=>0],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
echo "Development migration verified: existing records identical; zero profiles and zero results.\n";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app=require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$input=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($input['actor'],$input['tenant'],$input['company'],$input['unit']);
|
||||
if ($input['pause'] ?? false) {
|
||||
$paused=false;
|
||||
\Illuminate\Support\Facades\DB::listen(function ($query) use (&$paused): void {
|
||||
if (!$paused && str_contains($query->sql,'"companies"') && str_contains($query->sql,'for update')) {
|
||||
$paused=true; echo "LOCK_HELD\n"; flush(); usleep(1500000);
|
||||
}
|
||||
});
|
||||
}
|
||||
$result=app(\App\Modules\Services\ServiceCalculation::class)->calculate($input['draft'],$input['actor'],$input['command']);
|
||||
echo json_encode($result,JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (!\App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { throw new RuntimeException('Own local development database required.'); }
|
||||
$args=array_values(array_filter(array_slice($argv,1),fn($v)=>$v!=='--apply'));
|
||||
if(count($args)!==3 || array_filter($args,fn($v)=>!ctype_digit($v)))throw new RuntimeException('Usage: catalogue-accounting-access.php proposer_id validator_id company_id [--apply]; exact prior confirmation required.');
|
||||
[$proposer,$validator,$companyId]=array_map('intval',$args);if($proposer===$validator)throw new RuntimeException('The agreed workflow uses distinct preparer and validator accounts.');
|
||||
$db=\Illuminate\Support\Facades\DB::class;
|
||||
$company=$db::table('companies')->where('id',$companyId)->first(['id','tenant_id','name']);
|
||||
$accounts=$db::table('users')->whereIn('id',[$proposer,$validator])->orderBy('id')->get(['id','name','email']);
|
||||
$operations=[];
|
||||
foreach ($accounts as $account) {
|
||||
if (!$company || !$db::table('company_memberships')->where('user_id',$account->id)->where('company_id',$company->id)->where('tenant_id',$company->tenant_id)->exists()) { throw new RuntimeException('Existing membership missing. No association inferred.'); }
|
||||
foreach (['catalogue_accounting.view',$account->id===$proposer ? 'catalogue_accounting.propose' : 'catalogue_accounting.approve'] as $permission) { $operations[]=['user_id'=>$account->id,'tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'unit_id'=>null,'permission'=>$permission]; }
|
||||
}
|
||||
if ($accounts->count()!==2) { throw new RuntimeException('Two existing accounts required.'); }
|
||||
if (!in_array('--apply',$argv,true)) { echo json_encode(['accounts'=>$accounts,'company'=>$company,'proposed_grants'=>$operations,'applied'=>false],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL; exit; }
|
||||
$inserted=$db::transaction(function()use($db,$company,$operations){
|
||||
$db::table('companies')->where('id',$company->id)->where('tenant_id',$company->tenant_id)->lockForUpdate()->firstOrFail();$count=0;
|
||||
foreach($operations as $operation){
|
||||
foreach(['company.view','services.view'] as $right)app(\App\Modules\Identity\Services\Permissions::class)->require($operation['user_id'],$company->tenant_id,$company->id,$right);
|
||||
if(!$db::table('company_memberships')->where('user_id',$operation['user_id'])->where('tenant_id',$company->tenant_id)->where('company_id',$company->id)->exists())throw new RuntimeException('Membership changed. No grant applied.');
|
||||
if($db::table('permission_grants')->insertOrIgnore($operation+['created_at'=>now(),'updated_at'=>now()])){
|
||||
$count++;\App\Modules\Identity\Services\Audit::record('catalogue_accounting.access.initial_grant',null,$company->tenant_id,$company->id,subject:$operation['user_id'],details:['permission'=>$operation['permission'],'reason'=>'Exact initial classification grants specifically confirmed by the user; no fiscal or posting powers.']);
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
});
|
||||
echo "Applied $inserted specifically confirmed classification grants. Existing rights preserved.\n";
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { throw new RuntimeException('Own local development database required.'); }
|
||||
$args = array_values(array_filter(array_slice($argv, 1), fn ($arg) => $arg !== '--apply'));
|
||||
if (count($args) !== 4 || array_filter($args, fn ($id) => ! ctype_digit($id))) { throw new RuntimeException('Usage: document-access.php editor_id reader_id company_id unit_id [--apply]; specific confirmation required.'); }
|
||||
[$editor, $reader, $company, $unit] = array_map('intval', $args);
|
||||
if ($editor === $reader) { throw new RuntimeException('The two specifically confirmed accounts must be distinct.'); }
|
||||
$db = \Illuminate\Support\Facades\DB::class;
|
||||
$scope = $db::table('units as n')->join('companies as c', 'n.company_id', '=', 'c.id')->where('n.id', $unit)->where('c.id', $company)->first(['n.tenant_id','n.company_id','n.id as unit_id','n.name as unit_name','c.name as company_name']);
|
||||
if (! $scope) { throw new RuntimeException('Existing scope not found.'); }
|
||||
$plan = [];
|
||||
foreach ([$editor => ['view','edit'], $reader => ['view']] as $user => $actions) {
|
||||
$account = $db::table('users')->where('id', $user)->first(['id','name','email']);
|
||||
if (! $account) { throw new RuntimeException('Existing account not found; no account created.'); }
|
||||
foreach (['issuer_documents','client_documents','draft_documents'] as $area) { foreach ($actions as $action) { $plan[] = ['user' => $account, 'tenant_id' => $scope->tenant_id, 'company_id' => $company, 'unit_id' => $area === 'draft_documents' ? $unit : null, 'permission' => "$area.$action"]; } }
|
||||
}
|
||||
if (! in_array('--apply', $argv, true)) { echo json_encode(['scope' => $scope, 'grants' => $plan], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL; exit(0); }
|
||||
$db::transaction(function () use ($db, $scope, $plan): void {
|
||||
$db::table('companies')->where('id', $scope->company_id)->lockForUpdate()->firstOrFail();
|
||||
foreach ($plan as $grant) {
|
||||
$user = $grant['user']->id;
|
||||
foreach (['company.view','unit.view'] as $permission) { app(\App\Modules\Identity\Services\Permissions::class)->require($user, $scope->tenant_id, $scope->company_id, $permission, $permission === 'unit.view' ? $scope->unit_id : null); }
|
||||
$row = array_diff_key($grant, ['user' => true]) + ['user_id' => $user];
|
||||
if ($db::table('permission_grants')->insertOrIgnore($row + ['created_at' => now(), 'updated_at' => now()])) { \App\Modules\Identity\Services\Audit::record('documents.access.initial_grant', null, $scope->tenant_id, $scope->company_id, $grant['unit_id'], $user, ['permission' => $grant['permission'], 'reason' => 'Preparação documental: concessão especificamente confirmada pelo utilizador.']); }
|
||||
}
|
||||
});
|
||||
echo "Specifically confirmed document preparation grants applied; no accounts or financial powers created.\n";
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const fixturePath = 'C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
|
||||
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
|
||||
const base = 'http://127.0.0.1:8001';
|
||||
function client() {
|
||||
const cookies = new Map(); let csrf = ''; let token = '';
|
||||
return {
|
||||
async request(path, method = 'GET', body, context = token) {
|
||||
const response = await fetch(base + path, { method, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '), 'X-CSRF-TOKEN': csrf, 'X-SGF-Context': context, 'Idempotency-Key': randomUUID() }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
for (const cookie of response.headers.getSetCookie()) { const pair = cookie.split(';', 1)[0], split = pair.indexOf('='); cookies.set(pair.slice(0, split), pair.slice(split + 1)); }
|
||||
const data = await response.json().catch(() => ({})); if (data.csrf) csrf = data.csrf; if (data.context?.token) token = data.context.token;
|
||||
return { status: response.status, data };
|
||||
},
|
||||
async login(email) { await this.request('/api/session'); assert.equal((await this.request('/api/login', 'POST', { email, password: fixture.password })).status, 200); return this.select(); },
|
||||
async select(unit = fixture.unit, company = fixture.company) { const response = await this.request('/api/context', 'POST', { tenant_id: fixture.tenant, company_id: company, unit_id: unit }); assert.equal(response.status, 200); return response.data.context.token; }
|
||||
};
|
||||
}
|
||||
const manager = client(), reader = client(); await manager.login('gestor@example.test'); await reader.login('consulta@example.test');
|
||||
async function create(area, data) { const result = await manager.request('/api/a/' + area, 'POST', data); assert.equal(result.status, 201); return result.data.id; }
|
||||
async function save(kind, id, data) { const result = await manager.request(`/api/document-preparation/${kind}/${id}`, 'PATCH', data); assert.equal(result.status, 200); return result.data; }
|
||||
const customer = await create('clients', { name: 'Cliente documental de demonstração' });
|
||||
const service = await create('services', { name: 'Serviço documental de demonstração' });
|
||||
const source = await create('service_drafts', { client_id: customer, lines: [{ service_id: service, quantity: '1.25', unit_price: '0.00000001' }] });
|
||||
const oldDraft = await create('billing_drafts', { source_id: source, name: 'B1 anterior à preparação documental' });
|
||||
assert.equal((await manager.request(`/api/b1/billing-drafts/${oldDraft}/confirm-internal`, 'POST', { version: 1 })).status, 200);
|
||||
const oldBefore = (await manager.request(`/api/a/billing_drafts/${oldDraft}`)).data.confirmation;
|
||||
assert.equal(oldBefore.snapshot.schema_version, undefined);
|
||||
const profile = { version: 0, nuit: 'IDENTIFICAÇÃO SINTÉTICA', address: 'Morada sintética', locality: 'Localidade de teste', country: 'País de teste' };
|
||||
await save('issuer', fixture.company, profile); await save('client', customer, profile);
|
||||
const privateMarker = 'SYNTHETIC-' + randomUUID();
|
||||
const paymentData = { version: 0, label: 'Instruções de demonstração — sem operação bancária', bank: 'Banco fictício de teste', holder: 'Titular sintético', account_number: privateMarker, instructions: 'Apenas instruções sintéticas; não efectuar pagamentos.', currency: null };
|
||||
const instruction = (await save('payment', 0, paymentData)).id;
|
||||
const preparation = { version: 0, draft_version: 1, currency: null, service_date: '2026-11-01', period_start: '2026-11-01', period_end: '2026-11-30', payment_terms: 'Prazo a conferir com vencimento; sem regra aprovada', proposed_due_date: '2026-11-15', price_mode: 'undefined', units: ['hora'], payment_instruction_ids: [instruction] };
|
||||
await save('service_drafts', source, preparation);
|
||||
const newDraft = await create('billing_drafts', { source_id: source, name: 'Documento interno com preparação versão 2' });
|
||||
const reused = (await manager.request(`/api/document-preparation/billing_drafts/${newDraft}`)).data;
|
||||
assert.equal(reused.data.units[0], 'hora'); assert.equal(reused.data.payment_instruction_ids[0], instruction); assert.equal(reused.currency, null);
|
||||
assert.equal((await manager.request(`/api/b1/billing-drafts/${newDraft}/confirm-internal`, 'POST', { version: 1 })).status, 200);
|
||||
const snapshot = (await manager.request(`/api/a/billing_drafts/${newDraft}`)).data.confirmation;
|
||||
assert.equal(snapshot.snapshot.schema_version, 2); assert.equal(snapshot.snapshot.document_preparation.payment_instructions[0].data.account_number, privateMarker);
|
||||
await save('payment', instruction, { ...paymentData, version: 1, instructions: 'Instruções sintéticas revistas após confirmação.' });
|
||||
assert.deepEqual((await manager.request(`/api/a/billing_drafts/${oldDraft}`)).data.confirmation, oldBefore);
|
||||
assert.deepEqual((await manager.request(`/api/a/billing_drafts/${newDraft}`)).data.confirmation, snapshot);
|
||||
assert.equal((await reader.request(`/api/document-preparation/payment/${instruction}`)).status, 200);
|
||||
assert.equal((await reader.request(`/api/document-preparation/payment/${instruction}`, 'PATCH', { ...paymentData, version: 2 })).status, 403);
|
||||
assert.equal((await manager.request(`/api/document-preparation/payment/${instruction}`, 'PATCH', { ...paymentData, version: 1 })).status, 409);
|
||||
const oldContext = await manager.select(); await manager.select(null);
|
||||
assert.equal((await manager.request(`/api/document-preparation/issuer/${fixture.company}`, 'PATCH', { ...profile, version: 1 }, oldContext)).status, 409);
|
||||
await manager.select(null, fixture.sibling);
|
||||
assert.equal((await manager.request(`/api/document-preparation/payment/${instruction}`)).status, 403);
|
||||
await manager.select();
|
||||
const editableDraft = await create('billing_drafts', { source_id: source, name: 'Percurso visual — preparação documental' });
|
||||
fixture.document_client_id = customer; fixture.document_source_id = source; fixture.document_draft_id = editableDraft;
|
||||
await fs.writeFile(fixturePath, JSON.stringify(fixture));
|
||||
await manager.request('/api/logout', 'POST'); await reader.request('/api/logout', 'POST');
|
||||
await fs.writeFile('docs/proofs/document-http-results.json', JSON.stringify({ database: 'sgf_tempo_test', real_synthetic_login: true, results: ['Client created with name only; company/client profiles reused.', 'Source preparation and selected issuer instruction reused in billing draft.', 'Existing B1 v1 and future v2 snapshots unchanged after configuration updates.', 'Reader can consult instructions; editing denied 403.', 'Old version/context denied 409; other company denied 403.', 'Currency unknown, exact prices unchanged; no receipt, issue or accounting operation.'], instruction_values: 'omitted' }, null, 2));
|
||||
console.log('Isolated document preparation HTTP proof passed. Instruction values omitted.');
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { throw new RuntimeException('Own local development database required.'); }
|
||||
$path = 'C:/laragon/data/sgf-tempo/run/document-preservation.json';
|
||||
$tables = ['users','tenants','companies','units','company_memberships','a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_outbox','a_pending','a_commands','b_internal_confirmations','doc_records','doc_history'];
|
||||
$state = [];
|
||||
foreach ($tables as $table) {
|
||||
$rows = \Illuminate\Support\Facades\DB::table($table)->get()->map(fn ($row) => json_encode((array) $row, JSON_THROW_ON_ERROR))->all(); sort($rows);
|
||||
$state[$table] = ['count' => count($rows), 'digest' => hash('sha256', implode("\n", $rows))];
|
||||
}
|
||||
if (($argv[1] ?? '') === 'before') { file_put_contents($path, json_encode($state, JSON_THROW_ON_ERROR)); echo "Local preservation baseline recorded; no record values displayed.\n"; }
|
||||
elseif (($argv[1] ?? '') === 'after') {
|
||||
$before = json_decode(file_get_contents($path), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($before !== $state) { throw new RuntimeException('Existing record preservation comparison failed; inspect locally without printing values.'); }
|
||||
echo "Existing identity, A records and B1 snapshots match the pre-migration baseline exactly.\n";
|
||||
} else { throw new RuntimeException('Usage: document-preservation.php before|after'); }
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$db = \Illuminate\Support\Facades\DB::class;
|
||||
$kinds = $db::table('doc_records')->selectRaw('kind,count(*) AS count')->groupBy('kind')->orderBy('kind')->get()->all();
|
||||
$schemas = ['1' => 0, '2' => 0];
|
||||
foreach ($db::table('b_internal_confirmations')->pluck('snapshot') as $encoded) { $snapshot = json_decode($encoded, true, 64, JSON_THROW_ON_ERROR); $schemas[(string) ($snapshot['schema_version'] ?? 1)]++; }
|
||||
$privateValues = $db::table('doc_records')->where('kind', 'payment')->pluck('data')->map(fn ($value) => json_decode($value, true, 32, JSON_THROW_ON_ERROR)['account_number'] ?? null)->filter()->all();
|
||||
$audit = $db::table('identity_audit')->pluck('details')->implode(''); $log = is_file(storage_path('logs/laravel.log')) ? file_get_contents(storage_path('logs/laravel.log')) : '';
|
||||
foreach ($privateValues as $value) { if (str_contains($audit, $value) || str_contains($log, $value)) { throw new RuntimeException('Instruction value detected in audit or technical log; no value displayed.'); } }
|
||||
$report = ['database' => 'sgf_tempo_test', 'preparation_records_by_kind' => $kinds, 'preparation_history_versions' => $db::table('doc_history')->count(), 'confirmation_snapshot_schemas' => $schemas, 'instruction_values_in_audit_or_technical_log' => false, 'instruction_values' => 'omitted', 'receipt_table_present' => \Illuminate\Support\Facades\Schema::hasTable('receipts'), 'journal_table_present' => \Illuminate\Support\Facades\Schema::hasTable('journal_entries')];
|
||||
file_put_contents(base_path('docs/proofs/document-capture.json'), json_encode($report, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
echo "Document capture metrics recorded with no instruction values; v1/v2 schemas and audit/log confidentiality checked.\n";
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { throw new RuntimeException('Own local development database required.'); }
|
||||
$db = \Illuminate\Support\Facades\DB::class; $counts = [];
|
||||
foreach (['users','companies','units','a_clients','a_services','a_service_drafts','a_billing_drafts','b_internal_confirmations','doc_records','doc_history'] as $table) { $counts[$table] = $db::table($table)->count(); }
|
||||
$grants = $db::table('permission_grants')->whereIn('permission', ['issuer_documents.view','issuer_documents.edit','client_documents.view','client_documents.edit','draft_documents.view','draft_documents.edit'])->orderBy('user_id')->orderBy('permission')->get(['user_id','company_id','unit_id','permission']);
|
||||
echo json_encode(['database' => 'sgf_tempo_dev', 'counts' => $counts, 'document_grants' => $grants], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;
|
||||
@@ -9,7 +9,7 @@ foreach (['users', 'tenants', 'companies', 'units'] as $table) {
|
||||
fwrite(STDERR, "Test database is not empty. No data was deleted. Stop other test workflows and prepare the test database explicitly.\n"); exit(1);
|
||||
}
|
||||
}
|
||||
$fixture = \Illuminate\Support\Facades\DB::transaction(fn () => \Tests\Support\InternalConfirmationFixture::create());
|
||||
$fixture = \Illuminate\Support\Facades\DB::transaction(fn () => \Tests\Support\DocumentPreviewFixture::create());
|
||||
$safe = array_intersect_key($fixture, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
|
||||
$safe['manager_id'] = $fixture['manager']->id;
|
||||
$safe['reader_id'] = $fixture['reader']->id;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { throw new RuntimeException('Own local development database required.'); }
|
||||
$args = array_values(array_filter(array_slice($argv, 1), fn ($arg) => $arg !== '--apply'));
|
||||
if (count($args) !== 2 || array_filter($args, fn ($id) => ! ctype_digit($id))) { throw new RuntimeException('Usage: preview-access.php user_id company_id [--apply]; specific confirmation required.'); }
|
||||
[$user, $company] = array_map('intval', $args); $db = \Illuminate\Support\Facades\DB::class;
|
||||
$account = $db::table('users')->where('id', $user)->first(['id','name','email']);
|
||||
$scope = $db::table('companies')->where('id', $company)->first(['id','tenant_id','name']);
|
||||
if (! $account || ! $scope) { throw new RuntimeException('Existing account/company not found; nothing created.'); }
|
||||
$permission = \App\Modules\Services\DocumentPreview::CONFIGURE;
|
||||
if (! in_array('--apply', $argv, true)) { echo json_encode(['account' => $account, 'company' => $scope, 'permission' => $permission, 'unit_id' => null], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL; exit(0); }
|
||||
$db::transaction(function () use ($db, $scope, $user, $permission): void {
|
||||
$db::table('companies')->where('id', $scope->id)->lockForUpdate()->firstOrFail();
|
||||
foreach (['company.view','issuer_documents.view'] as $right) { app(\App\Modules\Identity\Services\Permissions::class)->require($user, $scope->tenant_id, $scope->id, $right); }
|
||||
if ($db::table('permission_grants')->insertOrIgnore(['user_id' => $user, 'tenant_id' => $scope->tenant_id, 'company_id' => $scope->id, 'unit_id' => null, 'permission' => $permission, 'created_at' => now(), 'updated_at' => now()])) {
|
||||
\App\Modules\Identity\Services\Audit::record('document_preview.access.initial_grant', null, $scope->tenant_id, $scope->id, subject: $user, details: ['permission' => $permission, 'reason' => 'Configuração da apresentação especificamente confirmada pelo utilizador.']);
|
||||
}
|
||||
});
|
||||
echo "Specifically confirmed company preview-configuration grant applied; no financial powers.\n";
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$fixture = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($fixture['manager_id'], $fixture['tenant'], $fixture['company'], $fixture['unit']);
|
||||
$preparation = app(\App\Modules\Services\DocumentPreparation::class);
|
||||
$issuer = $preparation->show('issuer', $fixture['company'], $fixture['manager_id']);
|
||||
$client = $preparation->show('client', $fixture['document_client_id'], $fixture['manager_id']);
|
||||
$input = ['actor' => $fixture['manager_id'], 'tenant' => $fixture['tenant'], 'company' => $fixture['company'], 'unit' => $fixture['unit'], 'draft' => $fixture['document_draft_id'], 'issuer' => $issuer['data']['nuit'], 'client' => $client['data']['nuit']];
|
||||
$worker = new Process([PHP_BINARY, 'scripts/preview-coherence-worker.php', '--env=testing'], base_path(), null, json_encode($input));
|
||||
$worker->start();
|
||||
if (! $worker->waitUntil(fn ($type, $buffer) => str_contains($buffer, 'SNAPSHOT_STARTED'))) { throw new RuntimeException('Could not establish concurrent snapshot.'); }
|
||||
DB::transaction(function () use ($preparation, $fixture, $issuer, $client): void {
|
||||
$preparation->save('issuer', $fixture['company'], $fixture['manager_id'], ['version' => (int) $issuer['version']] + array_replace($issuer['data'], ['nuit' => 'EMITENTE-SINTETICO-REVISTO']));
|
||||
$preparation->save('client', $fixture['document_client_id'], $fixture['manager_id'], ['version' => (int) $client['version']] + array_replace($client['data'], ['nuit' => 'CLIENTE-SINTETICO-REVISTO']));
|
||||
});
|
||||
$worker->wait(); if (! $worker->isSuccessful()) { throw new RuntimeException('Synthetic snapshot process failed.'); }
|
||||
$lines = array_values(array_filter(explode("\n", trim($worker->getOutput())), fn ($line) => str_starts_with($line, '{')));
|
||||
$result = json_decode(end($lines), true, 32, JSON_THROW_ON_ERROR);
|
||||
if (in_array(false, $result, true)) { throw new RuntimeException('Mixed snapshot detected.'); }
|
||||
$next = app(\App\Modules\Services\DocumentPreview::class)->read($fixture['document_draft_id'], $fixture['manager_id']);
|
||||
if ($next['issuer']['data']['nuit'] !== 'EMITENTE-SINTETICO-REVISTO' || $next['client']['data']['nuit'] !== 'CLIENTE-SINTETICO-REVISTO') { throw new RuntimeException('Explicit refresh did not observe committed changes.'); }
|
||||
file_put_contents(base_path('docs/proofs/preview-coherence.json'), json_encode(['database' => 'sgf_tempo_test', 'parallel_php_processes' => true, 'repeatable_read_verified' => true, 'old_consultation' => 'both original versions', 'explicit_new_consultation' => 'both committed new versions', 'mixed_versions' => false, 'instruction_values' => 'omitted'], JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
echo "Concurrent preview coherence passed: old snapshot remained coherent; new consultation saw both committed versions.\n";
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$input = json_decode(stream_get_contents(STDIN), true, 32, JSON_THROW_ON_ERROR);
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($input['actor'], $input['tenant'], $input['company'], $input['unit']);
|
||||
$paused = false; $isolation = null;
|
||||
\Illuminate\Support\Facades\DB::listen(function ($query) use (&$paused, &$isolation): void {
|
||||
if (! $paused && str_contains($query->sql, 'from "a_billing_drafts"')) {
|
||||
$paused = true;
|
||||
$isolation = \Illuminate\Support\Facades\DB::selectOne('SHOW transaction_isolation')->transaction_isolation;
|
||||
echo "SNAPSHOT_STARTED\n"; flush(); usleep(2000000);
|
||||
}
|
||||
});
|
||||
$result = app(\App\Modules\Services\DocumentPreview::class)->read($input['draft'], $input['actor']);
|
||||
echo json_encode(['repeatable_read' => $isolation === 'repeatable read', 'original_issuer' => $result['issuer']['data']['nuit'] === $input['issuer'], 'original_client' => $result['client']['data']['nuit'] === $input['client']], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const fixture = JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json','utf8'));
|
||||
const base = 'http://127.0.0.1:8001';
|
||||
function client() {
|
||||
const cookies = new Map(); let csrf = '', context = '';
|
||||
return {
|
||||
async request(path, method = 'GET', body, token = context) {
|
||||
const response = await fetch(base + path, { method, headers: { Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v]) => `${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':token,'Idempotency-Key':randomUUID() },...(body === undefined ? {} : {body:JSON.stringify(body)}) });
|
||||
for (const cookie of response.headers.getSetCookie()) { const pair=cookie.split(';',1)[0], split=pair.indexOf('='); cookies.set(pair.slice(0,split),pair.slice(split+1)); }
|
||||
const data=await response.json().catch(() => ({})); if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
|
||||
return {status:response.status,data,cache:response.headers.get('cache-control')};
|
||||
},
|
||||
async login(email) {await this.request('/api/session');assert.equal((await this.request('/api/login','POST',{email,password:fixture.password})).status,200);return this.select();},
|
||||
async select(unit=fixture.unit) {const response=await this.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:fixture.company,unit_id:unit});assert.equal(response.status,200);return response.data.context.token;}
|
||||
};
|
||||
}
|
||||
const manager=client(),reader=client();await manager.login('gestor@example.test');await reader.login('consulta@example.test');
|
||||
const path=`/api/document-preview/draft/${fixture.document_draft_id}`;
|
||||
const first=await manager.request(path);assert.equal(first.status,200);assert.ok(first.cache.includes('no-store'));assert.equal(first.data.notice,'RASCUNHO — SEM VALIDADE FISCAL');assert.equal(first.data.base_amount,'0.0000000125');assert.equal(first.data.data.currency,null);assert.ok(first.data.pending.tax_rule_pending);
|
||||
const model={version:0,show_logo:false,show_contacts:true,show_observations:true,show_payment_instructions:false,contact_text:'Contactos sintéticos da empresa',observations:'Observações iniciais da demonstração'};
|
||||
assert.equal((await manager.request('/api/document-preview/model','PATCH',model)).status,200);
|
||||
const revision=await manager.request(path+'/revision');assert.equal(revision.status,200);assert.notEqual(revision.data.revision,first.data.revision);assert.equal(Object.keys(revision.data).join(','),'revision');
|
||||
const refreshed=await manager.request(path);assert.equal(refreshed.data.observations,model.observations);assert.equal(refreshed.data.payments.length,0);assert.equal(refreshed.data.notice,first.data.notice);assert.ok(refreshed.data.pending.tax_rule_pending);
|
||||
assert.equal((await reader.request(path)).status,200);assert.equal((await reader.request('/api/document-preview/model','PATCH',{...model,version:1})).status,403);
|
||||
assert.equal((await manager.request('/api/document-preview/model','PATCH',{...model,version:1,show_notice:false})).status,422);
|
||||
assert.equal((await manager.request('/api/document-preview/model','PATCH',model)).status,409);
|
||||
const oldContext=await manager.select();await manager.select(null);assert.equal((await manager.request('/api/document-preview/model','PATCH',{...model,version:1},oldContext)).status,409);await manager.select();
|
||||
const list=await manager.request('/api/a/billing_drafts');const confirmed=list.data.records.find(record=>record.internal_confirmation);
|
||||
assert.ok(confirmed);const historyPath=`/api/document-preview/history/${confirmed.id}`;
|
||||
const history=await manager.request(historyPath);assert.equal(history.status,200);assert.equal(history.data.contacts,null);assert.equal(history.data.issuer.name,null);
|
||||
assert.equal((await manager.request('/api/document-preview/model','PATCH',{...model,version:1,observations:'Observações para a prova no navegador'})).status,200);
|
||||
const historyAfter=await manager.request(historyPath);assert.equal(historyAfter.data.revision,history.data.revision);assert.equal(historyAfter.data.contacts,null);
|
||||
await manager.request('/api/logout','POST');await reader.request('/api/logout','POST');
|
||||
await fs.writeFile('docs/proofs/preview-http-results.json',JSON.stringify({database:'sgf_tempo_test',real_synthetic_login:true,results:['Read-only composition reuses exact values and unknown currency; no-store headers.', 'Revision changes after configuration; explicit new read loads new values.', 'Reader consults but cannot configure (403); hidden mandatory content rejected (422).', 'Old configuration version and context rejected (409).', 'Historical consultation is independent of current configuration and does not reconstruct missing issuer name.'],instruction_values:'omitted'},null,2));
|
||||
console.log('Isolated preview HTTP proof passed; instruction values omitted.');
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { throw new RuntimeException('Own local development database required.'); }
|
||||
$db = \Illuminate\Support\Facades\DB::class; $counts = [];
|
||||
foreach (['document_preview_models','document_preview_model_history','doc_records','doc_history','b_internal_confirmations'] as $table) { $counts[$table] = $db::table($table)->count(); }
|
||||
echo json_encode(['database' => 'sgf_tempo_dev', 'counts' => $counts, 'configuration_grants' => $db::table('permission_grants')->where('permission', \App\Modules\Services\DocumentPreview::CONFIGURE)->get(['user_id','company_id','unit_id','permission'])], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
namespace Tests\Feature;
|
||||
use App\Modules\Services\CatalogueAccounting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\CatalogueAccountingFixture;
|
||||
use Tests\Support\IdentityFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class CatalogueAccountingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
private array $f;
|
||||
private string $token;
|
||||
private int $service;
|
||||
private int $draft;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->f=CatalogueAccountingFixture::create(); $this->login();
|
||||
$client=$this->a('clients',['name'=>'Cliente sintético'])->json('id');
|
||||
$this->service=$this->a('services',['name'=>'Serviço pontual sintético'])->json('id');
|
||||
$this->draft=$this->a('service_drafts',['client_id'=>$client,'lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'100']]])->json('id');
|
||||
}
|
||||
private function login(string $role='manager'): void
|
||||
{
|
||||
$this->postJson('/api/login',['email'=>$this->f[$role]->email,'password'=>$this->f['password']])->assertOk();
|
||||
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');
|
||||
}
|
||||
private function a(string $area,array $data) {return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/a/'.$area,$data)->assertCreated();}
|
||||
private function command(string $path,array $data=[],?string $key=null) {return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/catalogue-accounting'.$path,$data);}
|
||||
private function propose(string $kind,array $data,string $from='2026-01-01',array $extra=[]): int
|
||||
{
|
||||
return $this->command('/proposals',['kind'=>$kind,'data'=>$data,'valid_from'=>$from,'reason'=>'Fundamento sintético']+$extra)->assertOk()->json('id');
|
||||
}
|
||||
private function approve(int $id): void
|
||||
{
|
||||
$this->command('/proposals/'.$id.'/submit')->assertOk();$this->login('reader');$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Validação sintética das condições'])->assertOk();$this->login();
|
||||
}
|
||||
private function graph(): array
|
||||
{
|
||||
$plan=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION]);$this->approve($plan);
|
||||
$account=$this->propose('account',['plan_id'=>$plan,'code'=>'7.2.900','name'=>'Conta empresarial exclusivamente sintética','origin'=>'enterprise','movable'=>true,'evidence'=>'Desdobramento de teste, não conta oficial inventada.']);$this->approve($account);
|
||||
$category=$this->propose('category',['name'=>'Serviços normais sintéticos','account_id'=>$account,'conditions'=>'Natureza normal validada apenas para o caso sintético.']);$this->approve($category);
|
||||
$link=$this->propose('service_link',['service_id'=>$this->service,'category_id'=>$category]);$this->approve($link);
|
||||
return compact('plan','account','category','link');
|
||||
}
|
||||
private function resolve(string $date='2026-10-26') {return $this->getJson('/api/catalogue-accounting/services/'.$this->service.'/resolve?reference_date='.$date);}
|
||||
public function test_workflow_separates_preparation_validation_and_immutable_versions(): void
|
||||
{
|
||||
$id=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION]);
|
||||
$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Sem direito'])->assertForbidden();
|
||||
$this->command('/proposals/'.$id.'/submit')->assertOk(); $this->login('reader');
|
||||
$this->command('/proposals',['kind'=>'plan','data'=>['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'valid_from'=>'2026-01-01','reason'=>'Não permitido'])->assertForbidden();
|
||||
$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Tentativa com edição','data'=>[]])->assertUnprocessable();
|
||||
$key=(string)Str::uuid(); $decision=['decision'=>'refused','reason'=>'Evidência insuficiente'];
|
||||
$this->command('/proposals/'.$id.'/decision',$decision,$key)->assertOk()->assertJsonPath('replayed',false);
|
||||
$this->command('/proposals/'.$id.'/decision',$decision,$key)->assertOk()->assertJsonPath('replayed',true);
|
||||
$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Conflito'])->assertStatus(409);
|
||||
$this->login(); $v2=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'2026-01-01',['previous_id'=>$id]);
|
||||
$this->assertDatabaseMissing('ca_decisions',['proposal_id'=>$v2]);
|
||||
try{DB::transaction(fn()=>DB::table('ca_proposals')->where('id',$id)->update(['reason'=>'Edição indevida']));$this->fail('Mutable proposal');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_',$e->getMessage());}
|
||||
}
|
||||
public function test_pending_refused_and_future_exceptions_do_not_replace_valid_category(): void
|
||||
{
|
||||
$g=$this->graph(); $this->resolve()->assertOk()->assertJsonPath('origin','category_base');
|
||||
$data=['service_id'=>$this->service,'account_id'=>$g['account'],'conditions'=>'Excepção de ensaio'];
|
||||
$draft=$this->propose('service_override',$data); $this->resolve()->assertOk()->assertJsonPath('origin','category_base');
|
||||
$this->command('/proposals/'.$draft.'/submit')->assertOk(); $this->resolve()->assertOk()->assertJsonPath('origin','category_base');
|
||||
$this->login('reader');$this->command('/proposals/'.$draft.'/decision',['decision'=>'refused','reason'=>'Recusa sintética'])->assertOk();$this->login();
|
||||
$this->resolve()->assertOk()->assertJsonPath('origin','category_base');
|
||||
$future=$this->propose('service_override',$data,'2026-11-01',['previous_id'=>$draft]);$this->approve($future);
|
||||
$this->resolve('2026-10-31')->assertOk()->assertJsonPath('origin','category_base');
|
||||
$this->resolve('2026-11-01')->assertOk()->assertJsonPath('origin','explicit_service_exception');
|
||||
}
|
||||
public function test_explicit_replacement_preserves_dates_history_and_rejects_overlap(): void
|
||||
{
|
||||
$g=$this->graph();
|
||||
$capture=['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>$this->resolve()->json('revision')];
|
||||
$id=$this->command('/resolutions',$capture)->assertOk()->json('id'); $snapshot=DB::table('ca_resolutions')->where('id',$id)->value('snapshot');
|
||||
$data=['name'=>'Nova regra sintética','account_id'=>$g['account'],'conditions'=>'Condições revistas'];
|
||||
$overlap=$this->propose('category',$data,'2026-11-01',['previous_id'=>$g['category']]);
|
||||
$this->command('/proposals/'.$overlap.'/submit')->assertOk();$this->login('reader');$this->command('/proposals/'.$overlap.'/decision',['decision'=>'approved','reason'=>'Sobreposição indevida'])->assertStatus(409);$this->login();
|
||||
$revision=$this->propose('category',$data,'2026-11-01',['previous_id'=>$overlap,'replaces_id'=>$g['category']]);$this->approve($revision);
|
||||
$this->resolve('2026-10-31')->assertOk()->assertJsonPath('references.category.id',$g['category']);
|
||||
$this->resolve('2026-11-01')->assertOk()->assertJsonPath('references.category.id',$revision);
|
||||
$this->assertSame($snapshot,DB::table('ca_resolutions')->where('id',$id)->value('snapshot'));
|
||||
try{DB::transaction(fn()=>DB::table('ca_resolutions')->update(['unit_id'=>$this->f['hidden']]));$this->fail('Mass transfer accepted');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_',$e->getMessage());}
|
||||
}
|
||||
public function test_category_switch_cannot_bypass_validation_or_catalogue_protection(): void
|
||||
{
|
||||
$g=$this->graph(); $other=$this->propose('category',['name'=>'Outra categoria','account_id'=>$g['account'],'conditions'=>'Caso sintético']);$this->approve($other);
|
||||
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/services/'.$this->service,['version'=>1,'name'=>'Tentativa','category_id'=>$other])->assertUnprocessable();
|
||||
$link=$this->propose('service_link',['service_id'=>$this->service,'category_id'=>$other],'2026-11-01',['previous_id'=>$g['link'],'replaces_id'=>$g['link']]);
|
||||
$this->resolve('2026-12-01')->assertOk()->assertJsonPath('references.category.id',$g['category']);
|
||||
$this->approve($link); $this->resolve('2026-10-31')->assertOk()->assertJsonPath('references.category.id',$g['category']);$this->resolve('2026-11-01')->assertOk()->assertJsonPath('references.category.id',$other);
|
||||
}
|
||||
public function test_cross_company_operations_units_revocation_and_missing_date_are_denied(): void
|
||||
{
|
||||
$this->graph();
|
||||
$this->getJson('/api/catalogue-accounting/services/'.$this->service.'/resolve')->assertOk()->assertJsonPath('state','pending');
|
||||
$foreign=DB::table('a_services')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'name'=>'Serviço de outra empresa','data'=>'{}','version'=>1,'created_at'=>now(),'updated_at'=>now()]);
|
||||
$this->getJson('/api/catalogue-accounting/services/'.$foreign.'/resolve?reference_date=2026-10-26')->assertForbidden();
|
||||
$this->command('/proposals',['kind'=>'service_link','data'=>['service_id'=>$foreign,'category_id'=>1],'valid_from'=>'2026-01-01','reason'=>'Cruzamento'])->assertStatus(403);
|
||||
$this->login('reader'); $this->getJson('/api/catalogue-accounting')->assertOk();
|
||||
DB::table('permission_grants')->where('user_id',$this->f['reader']->id)->where('permission','catalogue_accounting.view')->delete(); $this->getJson('/api/catalogue-accounting')->assertForbidden();
|
||||
$this->login();
|
||||
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'unit.view',$this->f['hidden']);
|
||||
$new=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertOk()->json('context.token'); $this->token=$new;
|
||||
$this->command('/resolutions',['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>str_repeat('0',64)])->assertForbidden();
|
||||
$this->getJson('/api/catalogue-accounting/history/service_drafts/'.$this->draft)->assertForbidden();
|
||||
}
|
||||
public function test_stale_submission_identity_replay_and_no_fiscal_payloads(): void
|
||||
{
|
||||
$data=['kind'=>'plan','data'=>['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'valid_from'=>'2026-01-01','reason'=>'Proposta'];$key=(string)Str::uuid();
|
||||
$id=$this->command('/proposals',$data,$key)->assertOk()->json('id');$this->command('/proposals',$data,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
|
||||
$this->command('/proposals',array_replace($data,['reason'=>'Diferente']),$key)->assertStatus(409);
|
||||
$next=$this->propose('plan',$data['data'],'2026-01-01',['previous_id'=>$id]);$this->command('/proposals/'.$id.'/submit')->assertStatus(409);
|
||||
$this->command('/proposals',['kind'=>'plan','data'=>$data['data']+['vat_rate'=>'16'],'valid_from'=>'2026-01-01','reason'=>'Taxa indevida','previous_id'=>$next])->assertUnprocessable();
|
||||
$this->assertDatabaseCount('service_calculation_results',0); $this->assertDatabaseCount('b_internal_confirmations',0);
|
||||
}
|
||||
public function test_aggregate_missing_reference_and_incompatible_approved_override_are_not_silently_applied(): void
|
||||
{
|
||||
$g=$this->graph();
|
||||
$aggregate=$this->propose('account',['plan_id'=>$g['plan'],'code'=>'7.2','name'=>'Prestação de serviços','origin'=>'reference','movable'=>false,'evidence'=>'Referência agregadora do exemplar, sem conta de movimento.']);$this->approve($aggregate);
|
||||
$cat=$this->propose('category',['name'=>'Incompleta','account_id'=>$aggregate,'conditions'=>'Exige conta de movimento']);$this->command('/proposals/'.$cat.'/submit')->assertUnprocessable();
|
||||
$override=$this->propose('service_override',['service_id'=>$this->service,'account_id'=>$g['account'],'conditions'=>'Caso específico']);$this->approve($override);
|
||||
// An explicitly approved new account version expires the old reference. The override is now incompatible;
|
||||
// it must report the conflict rather than falling back silently to the category.
|
||||
$account=$this->propose('account',['plan_id'=>$g['plan'],'code'=>'7.2.900','name'=>'Conta sintética revista','origin'=>'enterprise','movable'=>true,'evidence'=>'Nova versão sintética'],'2026-11-01',['previous_id'=>$g['account'],'replaces_id'=>$g['account']]);$this->approve($account);
|
||||
$this->resolve('2026-11-01')->assertOk()->assertJsonPath('state','pending')->assertJsonPath('conflict',true);
|
||||
$this->command('/proposals',['kind'=>'category','data'=>['name'=>'Ausente','account_id'=>999999,'conditions'=>'Sem referência'],'valid_from'=>'2026-01-01','reason'=>'Ensaio'])->assertForbidden();
|
||||
}
|
||||
public function test_b1_snapshot_is_untouched_and_unauthorised_unit_cannot_insert_resolution_directly(): void
|
||||
{
|
||||
$this->graph(); $billing=$this->a('billing_drafts',['source_id'=>$this->draft])->json('id');
|
||||
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$billing.'/confirm-internal',['version'=>1])->assertOk();
|
||||
$before=DB::table('b_internal_confirmations')->get()->toJson();
|
||||
$this->command('/resolutions',['area'=>'billing_drafts','record_id'=>$billing,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>str_repeat('0',64)])->assertStatus(409);
|
||||
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
|
||||
try{DB::transaction(fn()=>DB::table('ca_resolutions')->insert(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden'],'area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','snapshot'=>'{}','actor_id'=>$this->f['manager']->id,'created_at'=>now()]));$this->fail('Cross-unit insert accepted');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_CA_OPERATION_SCOPE',$e->getMessage());}
|
||||
}
|
||||
public function test_capture_refuses_a_different_classification_than_the_one_consulted(): void
|
||||
{
|
||||
$g=$this->graph();$old=$this->resolve('2026-11-01')->assertOk()->json('revision');
|
||||
$id=$this->propose('category',['name'=>'Regra revista','account_id'=>$g['account'],'conditions'=>'Condições revistas'],'2026-11-01',['previous_id'=>$g['category'],'replaces_id'=>$g['category']]);$this->approve($id);
|
||||
$capture=['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-11-01','resolution_revision'=>$old];
|
||||
$this->command('/resolutions',$capture)->assertStatus(409);$this->assertDatabaseCount('ca_resolutions',0);
|
||||
$capture['resolution_revision']=$this->resolve('2026-11-01')->assertOk()->json('revision');$key=(string)Str::uuid();
|
||||
$this->command('/resolutions',$capture,$key)->assertOk()->assertJsonPath('replayed',false);$this->command('/resolutions',$capture,$key)->assertOk()->assertJsonPath('replayed',true);$this->assertDatabaseCount('ca_resolutions',1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Modules\Services\DocumentPreparation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\DocumentPreparationFixture;
|
||||
use Tests\Support\IdentityFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DocumentPreparationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
private array $fixture;
|
||||
private string $token;
|
||||
private int $client;
|
||||
private int $service;
|
||||
private int $source;
|
||||
private int $billing;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->fixture = DocumentPreparationFixture::create(); $this->login();
|
||||
$this->client = $this->writeA('clients', ['name' => 'Cliente documental sintético'])->assertCreated()->json('id');
|
||||
$this->service = $this->writeA('services', ['name' => 'Serviço documental sintético'])->assertCreated()->json('id');
|
||||
$this->source = $this->writeA('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id');
|
||||
$this->billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
|
||||
}
|
||||
private function login(string $kind = 'manager'): void
|
||||
{
|
||||
$this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk();
|
||||
$this->select($this->fixture['unit']);
|
||||
}
|
||||
private function select(?int $unit): void
|
||||
{
|
||||
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $unit])->assertOk()->json('context.token');
|
||||
}
|
||||
private function writeA(string $area, array $data, ?int $id = null)
|
||||
{
|
||||
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]);
|
||||
return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data);
|
||||
}
|
||||
private function save(string $kind, int $id, array $data)
|
||||
{
|
||||
return $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/$kind/$id", $data);
|
||||
}
|
||||
private function profile(): array { return ['version' => 0, 'nuit' => 'NUIT-SINTETICO', 'address' => 'Morada de teste', 'locality' => 'Local sintético', 'country' => 'País de teste']; }
|
||||
private function preparation(int $draftVersion = 1): array
|
||||
{
|
||||
return ['version' => 0, 'draft_version' => $draftVersion, 'currency' => null, 'service_date' => '2026-11-01', 'period_start' => '2026-11-01', 'period_end' => '2026-11-30', 'payment_terms' => 'Prazo a conferir', 'proposed_due_date' => '2026-11-15', 'price_mode' => 'undefined', 'units' => ['hora'], 'payment_instruction_ids' => []];
|
||||
}
|
||||
private function payment(): array { return ['version' => 0, 'label' => 'Instruções sintéticas', 'bank' => 'Banco fictício', 'holder' => 'Titular sintético', 'account_number' => 'SYNTHETIC-'.Str::uuid(), 'currency' => null, 'instructions' => 'Apenas simulação, não executar pagamentos.']; }
|
||||
private function confirm(): array
|
||||
{
|
||||
return $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertOk()->json();
|
||||
}
|
||||
private function confirmation(): array { return $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->json('confirmation'); }
|
||||
|
||||
public function test_client_starts_with_name_and_existing_names_are_reused_without_duplicate_fields(): void
|
||||
{
|
||||
$record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk();
|
||||
$record->assertJsonPath('version', 0)->assertJsonPath('name', 'Cliente documental sintético');
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$this->writeA('clients', ['name' => 'Nome revisto', 'version' => 1], $this->client)->assertOk();
|
||||
$record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk();
|
||||
$record->assertJsonPath('name', 'Nome revisto')->assertJsonPath('data.nuit', 'NUIT-SINTETICO');
|
||||
$this->assertArrayNotHasKey('name', $record->json('data'));
|
||||
$this->assertSame(1, DB::table('a_clients')->count());
|
||||
}
|
||||
public function test_issuer_and_payment_instructions_are_company_scoped_and_audited_without_values(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id');
|
||||
$this->getJson("/api/document-preparation/payment/$id")->assertOk()->assertJsonPath('data.account_number', $payment['account_number']);
|
||||
$this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode(''));
|
||||
$this->assertSame(2, DB::table('doc_history')->count());
|
||||
$this->select(null); $this->getJson('/api/document-preparation/payment')->assertOk()->assertJsonCount(1, 'records');
|
||||
}
|
||||
public function test_preparation_selects_instructions_without_receipt_and_preserves_exact_prices(): void
|
||||
{
|
||||
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $data['price_mode'] = 'included';
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.currency', null)->assertJsonPath('version', 1);
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
|
||||
$record->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.price_mode', 'included');
|
||||
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('receipts'));
|
||||
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries'));
|
||||
}
|
||||
public function test_preparation_reuses_source_dates_units_and_instructions_for_new_billing(): void
|
||||
{
|
||||
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id];
|
||||
$this->save('service_drafts', $this->source, $data)->assertOk();
|
||||
$billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$billing")->assertOk();
|
||||
$record->assertJsonPath('data.units.0', 'hora')->assertJsonPath('data.service_date', '2026-11-01')->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.source_preparation.version', 1);
|
||||
}
|
||||
public function test_completeness_distinguishes_missing_filled_and_pending_without_false_validation(): void
|
||||
{
|
||||
$before = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json();
|
||||
$this->assertSame('missing', collect($before['review'])->firstWhere('field', 'nuit')['state']);
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$after = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json();
|
||||
$this->assertSame('filled_not_validated', collect($after['review'])->firstWhere('field', 'nuit')['state']);
|
||||
$this->assertSame('validation_pending', collect($after['review'])->firstWhere('field', 'legal')['state']);
|
||||
$data = $this->preparation(); $data['payment_terms'] = 'Pagamento em 30 dias'; $data['proposed_due_date'] = '2026-11-02';
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
|
||||
$record->assertJsonPath('data.proposed_due_date', '2026-11-02');
|
||||
$this->assertSame('validation_pending', collect($record->json('review'))->firstWhere('field', 'terms_due')['state']);
|
||||
}
|
||||
public function test_reader_can_consult_but_not_change_any_new_area(): void
|
||||
{
|
||||
$payment = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
$this->login('reader');
|
||||
foreach (['issuer' => $this->fixture['company'], 'client' => $this->client, 'payment' => $payment, 'service_drafts' => $this->source, 'billing_drafts' => $this->billing] as $kind => $id) {
|
||||
$this->getJson("/api/document-preparation/$kind/$id")->assertOk();
|
||||
$this->save($kind, $id, ['version' => 0])->assertForbidden();
|
||||
}
|
||||
}
|
||||
public function test_no_implicit_new_rights_and_revocation_affects_next_request(): void
|
||||
{
|
||||
DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
|
||||
$this->getJson('/api/document-preparation/payment')->assertForbidden();
|
||||
$this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden();
|
||||
$this->getJson('/api/a/clients')->assertOk();
|
||||
}
|
||||
public function test_cross_company_same_tenant_and_cross_unit_are_refused(): void
|
||||
{
|
||||
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], 'company.view');
|
||||
foreach (['issuer_documents.view','issuer_documents.edit'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], $permission); }
|
||||
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => null])->assertOk()->json('context.token');
|
||||
$this->getJson("/api/document-preparation/payment/$id")->assertForbidden();
|
||||
$this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden();
|
||||
$this->save('payment', $id, $this->payment())->assertForbidden();
|
||||
$this->select($this->fixture['unit']);
|
||||
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
|
||||
foreach (['draft_documents.view','draft_documents.edit','billing_drafts.view'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); }
|
||||
$this->select($this->fixture['hidden']); $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertForbidden();
|
||||
}
|
||||
public function test_foreign_payment_selection_and_protected_fields_are_refused(): void
|
||||
{
|
||||
$foreign = DB::table('doc_records')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'kind' => 'payment', 'record_id' => 0, 'version' => 1, 'data' => json_encode(['label' => 'Outra empresa']), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$foreign];
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertForbidden();
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile() + ['company_id' => $this->fixture['sibling']])->assertUnprocessable();
|
||||
$this->save('payment', 0, $this->payment() + ['password' => 'not-collected'])->assertUnprocessable();
|
||||
}
|
||||
public function test_old_profile_draft_version_and_context_are_refused(): void
|
||||
{
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$this->save('client', $this->client, $this->profile())->assertStatus(409);
|
||||
$this->save('billing_drafts', $this->billing, $this->preparation(2))->assertStatus(409);
|
||||
$old = $this->token; $this->select(null);
|
||||
$this->withHeader('X-SGF-Context', $old)->patchJson('/api/document-preparation/issuer/'.$this->fixture['company'], $this->profile())->assertStatus(409);
|
||||
}
|
||||
public function test_currency_is_reused_and_synchronised_without_recalculating_tax(): void
|
||||
{
|
||||
$data = $this->preparation(); $data['currency'] = 'TST';
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('version', 2)->assertJsonPath('data.currency', 'TST')->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.tax_status', 'pending');
|
||||
$this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk()->assertJsonPath('data.draft_version', 2);
|
||||
}
|
||||
public function test_existing_v1_confirmation_is_unchanged_after_profile_and_payment_updates(): void
|
||||
{
|
||||
$this->confirm(); $before = $this->confirmation();
|
||||
$this->assertArrayNotHasKey('schema_version', $before['snapshot']);
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$this->save('payment', 0, $this->payment())->assertOk();
|
||||
$this->assertSame($before, $this->confirmation());
|
||||
$this->save('billing_drafts', $this->billing, $this->preparation())->assertStatus(409);
|
||||
}
|
||||
public function test_future_confirmation_v2_preserves_selected_instruction_version_and_blocks_unauthorized_read(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id');
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->confirm(); $before = $this->confirmation();
|
||||
$this->assertSame(2, $before['snapshot']['schema_version']);
|
||||
$this->assertDatabaseHas('a_events', ['id' => $before['event']['id'], 'schema_version' => 2]);
|
||||
$this->assertSame($payment['account_number'], $before['snapshot']['document_preparation']['payment_instructions'][0]['data']['account_number']);
|
||||
$payment['version'] = 1; $payment['instructions'] = 'Texto sintético revisto'; $this->save('payment', $id, $payment)->assertOk();
|
||||
$this->assertSame($before, $this->confirmation());
|
||||
$this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode(''));
|
||||
DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertForbidden();
|
||||
}
|
||||
public function test_invalid_dates_mode_and_precision_are_rejected(): void
|
||||
{
|
||||
$data = $this->preparation(); $data['period_end'] = '2026-10-31'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable();
|
||||
$data = $this->preparation(); $data['price_mode'] = 'tax-zero'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable();
|
||||
$this->writeA('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '0.123456789']]], $this->billing)->assertUnprocessable();
|
||||
}
|
||||
public function test_sql_mass_scope_transfer_is_blocked(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_DOC_IMMUTABLE_SCOPE');
|
||||
DB::table('doc_records')->update(['company_id' => $this->fixture['sibling'], 'version' => 2]);
|
||||
}
|
||||
public function test_history_is_append_only(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
|
||||
DB::table('doc_history')->update(['snapshot' => '{}']);
|
||||
}
|
||||
public function test_failure_rolls_back_and_does_not_expose_instruction_values(): void
|
||||
{
|
||||
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_doc_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_DOC_FAILURE'; END; \$\$; CREATE TRIGGER synthetic_doc_fail BEFORE INSERT ON doc_history FOR EACH ROW EXECUTE FUNCTION sgf_doc_test_fail();");
|
||||
$payment = $this->payment(); $response = $this->save('payment', 0, $payment)->assertStatus(500);
|
||||
$this->assertStringNotContainsString($payment['account_number'], $response->getContent());
|
||||
$this->assertSame(0, DB::table('doc_records')->count());
|
||||
$this->assertSame(0, DB::table('doc_history')->count());
|
||||
$log = storage_path('logs/laravel.log');
|
||||
$this->assertFalse(is_file($log) && str_contains(file_get_contents($log), $payment['account_number']), 'Instruction values must not appear in technical logs.');
|
||||
}
|
||||
public function test_preparation_becomes_stale_after_a_draft_edit_and_is_not_silently_reinterpreted(): void
|
||||
{
|
||||
$this->save('billing_drafts', $this->billing, $this->preparation())->assertOk();
|
||||
$this->writeA('billing_drafts', ['version' => 1, 'description' => 'Descrição revista'], $this->billing)->assertOk();
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
|
||||
$this->assertSame('stale', collect($record->json('review'))->firstWhere('field', 'draft_version')['state']);
|
||||
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 2])->assertStatus(409);
|
||||
$data = $this->preparation(2); $data['version'] = 1; $this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Modules\Services\DocumentPreview;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\DocumentPreviewFixture;
|
||||
use Tests\Support\IdentityFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DocumentPreviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
private array $fixture;
|
||||
private string $token;
|
||||
private int $client;
|
||||
private int $service;
|
||||
private int $source;
|
||||
private int $billing;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->fixture = DocumentPreviewFixture::create(); $this->login();
|
||||
$this->client = $this->writeA('clients', ['name' => 'Cliente da pré-visualização'])->assertCreated()->json('id');
|
||||
$this->service = $this->writeA('services', ['name' => 'Serviço da pré-visualização'])->assertCreated()->json('id');
|
||||
$this->source = $this->writeA('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id');
|
||||
$this->billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
|
||||
}
|
||||
private function login(string $kind = 'manager'): void
|
||||
{
|
||||
$this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk();
|
||||
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $this->fixture['unit']])->assertOk()->json('context.token');
|
||||
}
|
||||
private function writeA(string $area, array $data, ?int $id = null)
|
||||
{
|
||||
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]);
|
||||
return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data);
|
||||
}
|
||||
private function preview() { return $this->getJson("/api/document-preview/draft/$this->billing"); }
|
||||
private function model(array $changes = [], int $version = 0) { return $this->withHeader('X-SGF-Context', $this->token)->patchJson('/api/document-preview/model', ['version' => $version] + array_replace(DocumentPreview::DEFAULT_MODEL, $changes)); }
|
||||
private function confirm(): void { $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertOk(); }
|
||||
private function saveProfile(string $kind, int $id, string $value, int $version = 0): void { $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/$kind/$id", ['version' => $version, 'nuit' => $value, 'address' => 'Morada sintética', 'locality' => 'Localidade', 'country' => 'País de teste'])->assertOk(); }
|
||||
|
||||
public function test_preview_reuses_data_exact_arithmetic_and_unknowns_without_writing_business_records(): void
|
||||
{
|
||||
$tables = ['a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_pending','a_outbox','a_commands','b_internal_confirmations','doc_records','doc_history','document_preview_models','identity_audit'];
|
||||
$before = []; foreach ($tables as $table) { $before[$table] = DB::table($table)->count(); }
|
||||
$response = $this->preview()->assertOk()->assertJsonPath('notice', DocumentPreview::NOTICE)->assertJsonPath('data.lines.0.service_name', 'Serviço da pré-visualização')->assertJsonPath('client.name', 'Cliente da pré-visualização')->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.currency', null)->assertJsonPath('model_version', 0);
|
||||
$this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
|
||||
$this->assertArrayHasKey('tax_rule_pending', $response->json('pending'));
|
||||
foreach (['fiscal_number','issued_at','tax_amount','final_total'] as $field) { $this->assertArrayNotHasKey($field, $response->json()); }
|
||||
foreach ($before as $table => $count) { $this->assertSame($count, DB::table($table)->count()); }
|
||||
}
|
||||
public function test_missing_values_remain_unknown_while_explicit_zero_is_preserved(): void
|
||||
{
|
||||
$this->writeA('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => null, 'unit_price' => null]]], $this->billing)->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonPath('base_amount', null)->assertJsonPath('data.lines.0.unit_price', null);
|
||||
$this->writeA('billing_drafts', ['version' => 2, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '0']]], $this->billing)->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonPath('base_amount', '0')->assertJsonPath('data.lines.0.unit_price', '0');
|
||||
}
|
||||
public function test_configuration_is_explicit_reader_is_denied_and_old_version_conflicts(): void
|
||||
{
|
||||
$this->model(['contact_text' => 'Contactos sintéticos'])->assertOk()->assertJsonPath('version', 1);
|
||||
$this->model()->assertStatus(409);
|
||||
$this->login('reader'); $this->getJson('/api/document-preview/model')->assertOk(); $this->model(version: 1)->assertForbidden();
|
||||
$this->login(); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', DocumentPreview::CONFIGURE)->delete();
|
||||
$this->model(version: 1)->assertForbidden();
|
||||
}
|
||||
public function test_configuration_cannot_hide_notice_or_pending_and_rejects_external_resources(): void
|
||||
{
|
||||
$this->model(['show_notice' => false])->assertUnprocessable();
|
||||
$this->model(['pending' => []])->assertUnprocessable();
|
||||
$this->model(['logo_url' => 'https://example.invalid/logo.png'])->assertUnprocessable();
|
||||
$this->model(['show_contacts' => false, 'show_logo' => false, 'show_payment_instructions' => false])->assertOk();
|
||||
$result = $this->preview()->assertOk()->assertJsonPath('notice', DocumentPreview::NOTICE)->assertJsonPath('contacts', null);
|
||||
foreach (['tax_rule_pending','instructions_missing','contacts_missing','logo_missing'] as $field) { $this->assertArrayHasKey($field, $result->json('pending')); }
|
||||
}
|
||||
public function test_revision_detects_model_data_and_instruction_changes_without_replacing_prior_response(): void
|
||||
{
|
||||
$before = $this->preview()->assertOk()->json();
|
||||
$this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->assertExactJson(['revision' => $before['revision']]);
|
||||
$this->model(['observations' => 'Observação sintética nova'])->assertOk();
|
||||
$after = $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->json('revision');
|
||||
$this->assertNotSame($before['revision'], $after); $this->assertSame('', $before['observations']);
|
||||
$this->preview()->assertOk()->assertJsonPath('observations', 'Observação sintética nova');
|
||||
$this->saveProfile('client', $this->client, 'NUIT-SINTETICO');
|
||||
$this->assertNotSame($after, $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->json('revision'));
|
||||
}
|
||||
public function test_selected_instructions_and_price_mode_are_reused_without_fiscal_computation(): void
|
||||
{
|
||||
$marker = 'SYNTHETIC-'.Str::uuid();
|
||||
$payment = $this->withHeader('X-SGF-Context', $this->token)->patchJson('/api/document-preparation/payment/0', ['version' => 0, 'label' => 'Instruções sintéticas', 'account_number' => $marker])->assertOk()->json('id');
|
||||
$data = ['version' => 0, 'draft_version' => 1, 'currency' => null, 'price_mode' => 'included', 'units' => ['hora'], 'payment_instruction_ids' => [$payment]];
|
||||
$this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/billing_drafts/$this->billing", $data)->assertOk();
|
||||
$before = $this->preview()->assertOk()->assertJsonPath('prepared.price_mode', 'included')->assertJsonPath('base_amount', '0.0000000125');
|
||||
$this->assertTrue($before->json('payments.0.data.account_number') === $marker);
|
||||
$this->model(['show_payment_instructions' => false])->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonCount(0, 'payments')->assertJsonPath('base_amount', '0.0000000125');
|
||||
$this->assertStringNotContainsString($marker, DB::table('identity_audit')->pluck('details')->implode(''));
|
||||
}
|
||||
public function test_history_v1_uses_only_snapshot_and_never_current_profiles_or_model(): void
|
||||
{
|
||||
$this->confirm(); $before = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json();
|
||||
$this->saveProfile('issuer', $this->fixture['company'], 'NOVO-EMITENTE');
|
||||
$this->saveProfile('client', $this->client, 'NOVO-CLIENTE');
|
||||
$this->writeA('clients', ['version' => 1, 'name' => 'Nome actual diferente'], $this->client)->assertOk();
|
||||
$this->model(['contact_text' => 'NÃO HISTÓRICO', 'observations' => 'NÃO CAPTURADO'])->assertOk();
|
||||
$after = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json();
|
||||
unset($before['consulted_at'], $after['consulted_at']); $this->assertSame($before, $after);
|
||||
$this->assertNull($after['issuer']['name']); $this->assertSame([], $after['issuer']['data']);
|
||||
$this->assertSame('Cliente da pré-visualização', $after['client']['name']); $this->assertNull($after['contacts']);
|
||||
$this->preview()->assertStatus(409);
|
||||
}
|
||||
public function test_history_v2_keeps_captured_profile_and_payment_versions(): void
|
||||
{
|
||||
$this->saveProfile('issuer', $this->fixture['company'], 'EMITENTE-HISTÓRICO');
|
||||
$this->saveProfile('client', $this->client, 'CLIENTE-HISTÓRICO');
|
||||
$this->confirm();
|
||||
$before = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->assertJsonPath('snapshot_schema', 2)->assertJsonPath('issuer.data.nuit', 'EMITENTE-HISTÓRICO')->json();
|
||||
$this->saveProfile('issuer', $this->fixture['company'], 'EMITENTE-ACTUAL', 1);
|
||||
$after = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json();
|
||||
unset($before['consulted_at'], $after['consulted_at']); $this->assertSame($before, $after);
|
||||
$this->assertNull($after['issuer']['name']);
|
||||
}
|
||||
public function test_cross_unit_company_and_revoked_access_are_denied_on_next_consultation(): void
|
||||
{
|
||||
$this->preview()->assertOk();
|
||||
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
|
||||
foreach (['billing_drafts.view','draft_documents.view'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); }
|
||||
$this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $this->fixture['hidden']])->assertOk();
|
||||
$this->preview()->assertForbidden();
|
||||
$this->getJson("/api/document-preview/draft/$this->billing/revision")->assertForbidden();
|
||||
$this->login();
|
||||
$this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => null])->assertOk();
|
||||
$this->preview()->assertForbidden();
|
||||
$this->login(); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
|
||||
$this->getJson("/api/document-preview/draft/$this->billing/revision")->assertForbidden();
|
||||
}
|
||||
public function test_logo_is_local_png_only_and_no_history_reconstructs_it(): void
|
||||
{
|
||||
Storage::fake('local'); $path = 'document-logos/company-'.$this->fixture['company'].'.png';
|
||||
$this->model(['show_logo' => true])->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonPath('logo', null);
|
||||
Storage::disk('local')->put($path, '<svg onload="alert(1)"></svg>'); $this->preview()->assertOk()->assertJsonPath('logo', null);
|
||||
Storage::disk('local')->put($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jV0kAAAAASUVORK5CYII='));
|
||||
$logo = $this->preview()->assertOk()->json('logo'); $this->assertStringStartsWith('data:image/png;base64,', $logo);
|
||||
$this->confirm(); $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->assertJsonPath('logo', null);
|
||||
}
|
||||
public function test_model_scope_cannot_be_transferred_in_bulk(): void
|
||||
{
|
||||
$this->model()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_PREVIEW_IMMUTABLE_SCOPE_OR_VERSION');
|
||||
DB::table('document_preview_models')->update(['company_id' => $this->fixture['sibling'], 'version' => 2]);
|
||||
}
|
||||
public function test_model_history_is_immutable(): void
|
||||
{
|
||||
$this->model()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
|
||||
DB::table('document_preview_model_history')->delete();
|
||||
}
|
||||
public function test_configuration_failure_is_atomic_and_does_not_log_content(): void
|
||||
{
|
||||
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_preview_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_PREVIEW_FAILURE'; END; \$\$; CREATE TRIGGER preview_synthetic_fail BEFORE INSERT ON document_preview_model_history FOR EACH ROW EXECUTE FUNCTION sgf_preview_test_fail();");
|
||||
$this->model(['observations' => 'Texto sintético de falha'])->assertStatus(500);
|
||||
$this->assertSame(0, DB::table('document_preview_models')->count()); $this->assertSame(0, DB::table('document_preview_model_history')->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Modules\Services\ServiceCalculation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\DocumentPreviewFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ServiceCalculationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
private array $f;
|
||||
private int $draft;
|
||||
private int $profile;
|
||||
private string $token;
|
||||
private int $service;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->f = DocumentPreviewFixture::create(); $this->login();
|
||||
$client = $this->write('clients',['name'=>'Cliente sintético'])->json('id');
|
||||
$this->service = $this->write('services',['name'=>'Serviço sintético'])->json('id');
|
||||
$source = $this->write('service_drafts',['client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'1000.03']]])->json('id');
|
||||
$this->draft = $this->write('billing_drafts',['source_id'=>$source])->json('id');
|
||||
$this->withHeader('X-SGF-Context',$this->token)->patchJson('/api/document-preparation/billing_drafts/'.$this->draft,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','units'=>['UN'],'payment_instruction_ids'=>[]])->assertOk();
|
||||
$this->profile = $this->newProfile(1);
|
||||
}
|
||||
private function login(string $kind='manager'): void
|
||||
{
|
||||
$this->postJson('/api/login',['email'=>$this->f[$kind]->email,'password'=>$this->f['password']])->assertOk();
|
||||
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');
|
||||
}
|
||||
private function write(string $area,array $data,?int $id=null)
|
||||
{
|
||||
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()]);
|
||||
return ($id ? $this->patchJson("/api/a/$area/$id",$data) : $this->postJson("/api/a/$area",$data))->assertSuccessful();
|
||||
}
|
||||
private function newProfile(int $version): int
|
||||
{
|
||||
return DB::table('service_calculation_profiles')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'identifier'=>'SYNTHETIC-MZN-16','version'=>$version,'definition'=>json_encode(['kind'=>'synthetic','currency'=>'MZN','rate_percent'=>'16','policy'=>ServiceCalculation::POLICY,'sources'=>ServiceCalculation::SOURCES]),'created_at'=>now()]);
|
||||
}
|
||||
private function calculate(array $changes=[])
|
||||
{
|
||||
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-calculations/'.$this->draft,array_replace(['draft_version'=>1,'preparation_version'=>1,'profile_id'=>$this->profile,'profile_version'=>1],$changes));
|
||||
}
|
||||
private function preview() { return $this->getJson('/api/document-preview/draft/'.$this->draft)->assertOk(); }
|
||||
public function test_exact_values_truncation_boundaries_and_missing_rules(): void
|
||||
{
|
||||
$profile=['kind'=>'synthetic','currency'=>'MZN','rate_percent'=>'16','policy'=>ServiceCalculation::POLICY];
|
||||
foreach ([['100','16','16.00','0','116.00'],['1000.03','160.0048','160.00','0.0048','1160.03'],['0.03','0.0048','0.00','0.0048','0.03'],['0.06249999','0.0099999984','0.00','0.0099999984',null],['0.0625','0.01','0.01','0',null],['0.06250001','0.0100000016','0.01','0.0000000016',null],['0','0','0.00','0','0.00']] as [$base,$exact,$doc,$difference,$total]) {
|
||||
$result=ServiceCalculation::evaluate(['currency'=>'MZN','lines'=>[['quantity'=>'1','unit_price'=>$base]]],['price_mode'=>'added'],$profile);
|
||||
foreach (['vat_exact'=>$exact,'vat_document_proposed'=>$doc,'discarded_difference'=>$difference,'total_document_proposed'=>$total] as $field=>$expected) { $this->assertSame($expected,$result[$field],$base.' '.$field); }
|
||||
$this->assertFalse($result['fiscally_validated']);
|
||||
}
|
||||
$data=['currency'=>'MZN','lines'=>[['quantity'=>'1','unit_price'=>'0.03']]];
|
||||
foreach ([null,array_diff_key($profile,['rate_percent'=>true]),array_replace($profile,['currency'=>'USD']),array_replace($profile,['kind'=>'real']),array_replace($profile,['policy'=>'old'])] as $bad) { $this->assertSame('pending',ServiceCalculation::evaluate($data,['price_mode'=>'added'],$bad)['state']); }
|
||||
foreach ([[],['price_mode'=>'included']] as $prepared) { $this->assertSame('pending',ServiceCalculation::evaluate($data,$prepared,$profile)['state']); }
|
||||
$data['currency']=null; $result=ServiceCalculation::evaluate($data,['price_mode'=>'added'],$profile); $this->assertSame('pending',$result['state']); $this->assertArrayNotHasKey('vat_exact',$result);
|
||||
}
|
||||
public function test_persistence_repetition_versions_history_and_profile_changes(): void
|
||||
{
|
||||
$id=$this->calculate()->assertOk()->assertJsonPath('replayed',false)->json('id');
|
||||
$this->calculate()->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
|
||||
$this->assertDatabaseCount('service_calculation_results',1);
|
||||
$this->preview()->assertJsonPath('calculation.history.0.current',true)->assertJsonPath('calculation.history.0.snapshot.result.vat_exact','160.0048');
|
||||
$this->calculate(['draft_version'=>2])->assertStatus(409);
|
||||
$this->newProfile(2); $this->calculate()->assertStatus(409);
|
||||
$this->preview()->assertJsonPath('calculation.history.0.current',false);
|
||||
$this->write('billing_drafts',['version'=>1,'lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'0.03']]],$this->draft);
|
||||
$this->preview()->assertJsonPath('calculation.history.0.snapshot.result.vat_exact','160.0048');
|
||||
}
|
||||
public function test_reader_revocation_cross_scope_and_normal_environment_cannot_calculate(): void
|
||||
{
|
||||
$this->calculate()->assertOk(); $this->login('reader'); $this->preview()->assertJsonPath('calculation.can_calculate',false); $this->calculate()->assertForbidden();
|
||||
$this->login();
|
||||
$foreign=DB::table('service_calculation_profiles')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden'],'identifier'=>'FOREIGN','version'=>1,'definition'=>'{}','created_at'=>now()]);
|
||||
$this->calculate(['profile_id'=>$foreign])->assertForbidden();
|
||||
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
|
||||
$this->getJson('/api/document-preview/draft/'.$this->draft)->assertForbidden();
|
||||
$this->login(); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','billing_drafts.edit')->delete(); $this->calculate()->assertForbidden();
|
||||
$this->app->instance('env','local');
|
||||
try { app(ServiceCalculation::class)->calculate($this->draft,$this->f['manager']->id,[]); $this->fail('Normal environment accepted a synthetic calculation'); }
|
||||
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);
|
||||
$read=app(ServiceCalculation::class)->read($this->draft,$this->f['manager']->id);
|
||||
$this->assertSame('normal',$read['environment']); $this->assertSame([],$read['history']);
|
||||
}
|
||||
public function test_database_refuses_mass_transfer_and_b1_remains_unchanged(): void
|
||||
{
|
||||
$this->calculate()->assertOk();
|
||||
try { DB::transaction(fn()=>DB::table('service_calculation_results')->update(['unit_id'=>$this->f['hidden']])); $this->fail('Mass transfer accepted'); }
|
||||
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
|
||||
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$this->draft.'/confirm-internal',['version'=>1])->assertOk();
|
||||
$before=DB::table('b_internal_confirmations')->get()->toJson();
|
||||
$this->calculate()->assertStatus(409);
|
||||
$this->getJson('/api/document-preview/history/'.$this->draft)->assertOk()->assertJsonMissingPath('calculation');
|
||||
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace Tests\Support;
|
||||
final class CatalogueAccountingFixture
|
||||
{
|
||||
public static function create(): array
|
||||
{
|
||||
$f=DocumentPreviewFixture::create();
|
||||
foreach(['manager','reader'] as $role) {
|
||||
IdentityFixture::grant($f[$role]->id,$f['tenant'],$f['company'],'catalogue_accounting.view');
|
||||
IdentityFixture::grant($f[$role]->id,$f['tenant'],$f['company'],$role==='manager'?'catalogue_accounting.propose':'catalogue_accounting.approve');
|
||||
}
|
||||
return $f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class DocumentPreparationFixture
|
||||
{
|
||||
public static function create(): array
|
||||
{
|
||||
$fixture = InternalConfirmationFixture::create();
|
||||
foreach (['issuer_documents','client_documents','draft_documents'] as $area) {
|
||||
$unit = $area === 'draft_documents' ? $fixture['unit'] : null;
|
||||
foreach (['view','edit'] as $action) { IdentityFixture::grant($fixture['manager']->id, $fixture['tenant'], $fixture['company'], "$area.$action", $unit); }
|
||||
IdentityFixture::grant($fixture['reader']->id, $fixture['tenant'], $fixture['company'], "$area.view", $unit);
|
||||
}
|
||||
return $fixture;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class DocumentPreviewFixture
|
||||
{
|
||||
public static function create(): array
|
||||
{
|
||||
$fixture = DocumentPreparationFixture::create();
|
||||
IdentityFixture::grant($fixture['manager']->id, $fixture['tenant'], $fixture['company'], \App\Modules\Services\DocumentPreview::CONFIGURE);
|
||||
return $fixture;
|
||||
}
|
||||
}
|
||||